Skip to main content

animsmith_engine/
registry.rs

1use crate::canonical::facts_identity;
2use crate::{
3    AnimationAddressability, ConversionControl, CoordinateBasis, DefaultStatus, EngineProfile,
4    EngineProfileV2, FactId, FactState, FactValue, ForwardAxis, Handedness, LinearUnit,
5    PrimarySource, PrimarySourceV2, ProfileFact, ProfileSelection, RegistryValidationError,
6    RootMotionAddressability, SettingApplicability, SettingDefaultV2, SettingDescriptor,
7    SettingDescriptorV2, SettingDomain, SettingDomainV2, SettingId, SettingIdV2, SettingScope,
8    SettingValueV2, TargetAddressability, UpAxis,
9};
10use animsmith_core::{InputIdentity, SourceFormatV1};
11use std::collections::BTreeSet;
12use std::sync::OnceLock;
13
14const VERIFIED_ON: &str = "2026-08-20";
15const UNITY_GENERIC_V2_VERIFIED_ON: &str = "2026-08-26";
16const IMPORT_ADVICE_V2_VERIFIED_ON: &str = "2026-08-26";
17const BEVY_V2_VERIFIED_ON: &str = "2026-08-25";
18const BEVY_0_19_COMMIT: &str = "c6f634ca9f406d68ba5109d921247b654cb42c10";
19const ALL_FACT_IDS: [FactId; 14] = [
20    FactId::AcceptedInputs,
21    FactId::AnimationAddressability,
22    FactId::TargetCoordinateBasis,
23    FactId::TargetLinearUnit,
24    FactId::UnitConversionControl,
25    FactId::AxisConversionControl,
26    FactId::ExactAxisConversion,
27    FactId::ResultingHierarchyScale,
28    FactId::WholeEndFrameRequired,
29    FactId::AnimationChannelHandling,
30    FactId::ExtensionHandling,
31    FactId::ConstructHandling,
32    FactId::AnimationTargetAddressability,
33    FactId::RootMotionAddressability,
34];
35
36/// Enumerate the five immutable V1 engine profiles in tuple order.
37pub fn profiles_v1() -> &'static [EngineProfile] {
38    static PROFILES: OnceLock<Vec<EngineProfile>> = OnceLock::new();
39    PROFILES.get_or_init(|| {
40        let mut profiles = vec![unity_generic(), unity_humanoid(), unreal(), godot(), bevy()];
41        profiles.sort_by(|left, right| left.selection.cmp(&right.selection));
42        profiles
43    })
44}
45
46/// Enumerate immutable V2-contract profile records in exact tuple order.
47///
48/// This registry is separate from [`profiles_v1`], whose five records and
49/// canonical identities remain unchanged.
50pub fn profiles_v2() -> &'static [EngineProfileV2] {
51    static PROFILES: OnceLock<Vec<EngineProfileV2>> = OnceLock::new();
52    PROFILES.get_or_init(|| {
53        let mut profiles = vec![
54            unity_generic_v2(),
55            unreal_v2(),
56            godot_v2(),
57            bevy_v2(),
58            bevy_v3(),
59        ];
60        profiles.sort_by(|left, right| left.selection().cmp(right.selection()));
61        profiles
62    })
63}
64
65/// Validate revision-2 registry declarations and source cross-references.
66///
67/// # Errors
68///
69/// Returns a typed invariant error naming the affected profile or setting.
70pub fn validate_registry_v2() -> Result<(), RegistryValidationErrorV2> {
71    use animsmith_core::engine_contract::{EngineFactIdV2, EngineFactStateV2};
72    const FACT_IDS: [EngineFactIdV2; 10] = [
73        EngineFactIdV2::AcceptedInputs,
74        EngineFactIdV2::ApplicationWorldUnitPolicy,
75        EngineFactIdV2::ImporterScaleConversion,
76        EngineFactIdV2::ImportSettingProjection,
77        EngineFactIdV2::PhysicalDimensionsPreserved,
78        EngineFactIdV2::ResultingTransformScale,
79        EngineFactIdV2::RootMotionAddressability,
80        EngineFactIdV2::SourceImportDisposition,
81        EngineFactIdV2::SourceToTargetUnitMapping,
82        EngineFactIdV2::TargetLinearUnit,
83    ];
84    let profiles = profiles_v2();
85    if profiles.len() != 5 {
86        return Err(RegistryValidationErrorV2::ProfileCount {
87            found: profiles.len(),
88        });
89    }
90    let mut selections = BTreeSet::new();
91    let mut urns = BTreeSet::new();
92    for profile in profiles {
93        if !selections.insert(profile.selection().clone()) {
94            return Err(RegistryValidationErrorV2::DuplicateSelection {
95                selection: profile.selection().clone(),
96            });
97        }
98        if !urns.insert(profile.profile_urn()) {
99            return Err(RegistryValidationErrorV2::DuplicateProfileUrn {
100                urn: profile.profile_urn(),
101            });
102        }
103        if profile.accepted_inputs().is_empty()
104            || !profile.accepted_inputs().windows(2).all(|pair| {
105                crate::canonical::format_name(pair[0]) < crate::canonical::format_name(pair[1])
106            })
107        {
108            return Err(RegistryValidationErrorV2::InvalidAcceptedInputs {
109                selection: profile.selection().clone(),
110            });
111        }
112        let fact_ids: BTreeSet<_> = profile.facts().iter().map(|fact| fact.id()).collect();
113        if fact_ids.len() != profile.facts().len()
114            || fact_ids != FACT_IDS.into_iter().collect::<BTreeSet<_>>()
115        {
116            return Err(RegistryValidationErrorV2::InvalidFactInventory {
117                selection: profile.selection().clone(),
118            });
119        }
120
121        let setting_ids: BTreeSet<_> = profile
122            .setting_descriptors()
123            .iter()
124            .map(SettingDescriptorV2::id)
125            .collect();
126        if setting_ids.len() != profile.setting_descriptors().len() {
127            return Err(RegistryValidationErrorV2::DuplicateSettingDescriptor {
128                selection: profile.selection().clone(),
129            });
130        }
131        for descriptor in profile.setting_descriptors() {
132            if let SettingDefaultV2::Verified(value) = descriptor.default()
133                && !value.matches_domain(descriptor.domain())
134            {
135                return Err(RegistryValidationErrorV2::InvalidVerifiedDefault {
136                    selection: profile.selection().clone(),
137                    setting: descriptor.id(),
138                });
139            }
140        }
141
142        let mut source_ids = BTreeSet::new();
143        let mut supported_settings = BTreeSet::new();
144        let mut supported_facts = BTreeSet::new();
145        let mut accepted_inputs_supported = false;
146        for source in profile.sources() {
147            if !source_ids.insert(source.id()) {
148                return Err(RegistryValidationErrorV2::DuplicateSourceId {
149                    selection: profile.selection().clone(),
150                    source_id: source.id(),
151                });
152            }
153            accepted_inputs_supported |= source.supports_accepted_inputs();
154            for fact in source.supported_facts() {
155                let state = profile
156                    .facts()
157                    .iter()
158                    .find(|row| row.id() == *fact)
159                    .map(|row| row.state());
160                if !matches!(state, Some(EngineFactStateV2::Known(_))) {
161                    return Err(RegistryValidationErrorV2::UnknownSourceFact {
162                        selection: profile.selection().clone(),
163                        source_id: source.id(),
164                        fact: *fact,
165                    });
166                }
167                supported_facts.insert(*fact);
168            }
169            for setting in source.supported_settings() {
170                if !setting_ids.contains(setting) {
171                    return Err(RegistryValidationErrorV2::UnknownSourceSetting {
172                        selection: profile.selection().clone(),
173                        source_id: source.id(),
174                        setting: *setting,
175                    });
176                }
177                supported_settings.insert(*setting);
178            }
179        }
180        if !accepted_inputs_supported {
181            return Err(RegistryValidationErrorV2::UnreferencedAcceptedInputs {
182                selection: profile.selection().clone(),
183            });
184        }
185        if let Some(fact) = profile
186            .facts()
187            .iter()
188            .filter(|row| matches!(row.state(), EngineFactStateV2::Known(_)))
189            .map(|row| row.id())
190            .find(|fact| !supported_facts.contains(fact))
191        {
192            return Err(RegistryValidationErrorV2::UnreferencedFact {
193                selection: profile.selection().clone(),
194                fact,
195            });
196        }
197        if let Some(setting) = setting_ids.difference(&supported_settings).next() {
198            return Err(RegistryValidationErrorV2::UnreferencedSetting {
199                selection: profile.selection().clone(),
200                setting: *setting,
201            });
202        }
203    }
204    Ok(())
205}
206
207/// Author-owned invariant failure in the revision-2 registry.
208#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
209#[non_exhaustive]
210pub enum RegistryValidationErrorV2 {
211    /// The registry does not contain the authorized V2 profile set.
212    #[error("V2 registry contains {found} profiles rather than the authorized set")]
213    ProfileCount {
214        /// Actual profile count.
215        found: usize,
216    },
217    /// Two records use the same exact tuple.
218    #[error("duplicate V2 profile selection {selection:?}")]
219    DuplicateSelection {
220        /// Repeated selection.
221        selection: ProfileSelection,
222    },
223    /// Two records use the same stable record URN.
224    #[error("duplicate V2 profile URN {urn}")]
225    DuplicateProfileUrn {
226        /// Repeated URN.
227        urn: &'static str,
228    },
229    /// Accepted inputs are empty, repeated, or not canonically sorted.
230    #[error("invalid V2 accepted inputs for {selection:?}")]
231    InvalidAcceptedInputs {
232        /// Affected selection.
233        selection: ProfileSelection,
234    },
235    /// The record does not contain every V2 fact exactly once.
236    #[error("invalid V2 fact inventory for {selection:?}")]
237    InvalidFactInventory {
238        /// Affected selection.
239        selection: ProfileSelection,
240    },
241    /// A setting descriptor id is repeated.
242    #[error("duplicate V2 setting descriptor for {selection:?}")]
243    DuplicateSettingDescriptor {
244        /// Affected selection.
245        selection: ProfileSelection,
246    },
247    /// A verified default does not match its descriptor domain.
248    #[error("invalid V2 verified default for {setting:?} in {selection:?}")]
249    InvalidVerifiedDefault {
250        /// Affected selection.
251        selection: ProfileSelection,
252        /// Affected setting.
253        setting: SettingIdV2,
254    },
255    /// A primary-source id is repeated within one profile.
256    #[error("duplicate V2 source id {source_id} in {selection:?}")]
257    DuplicateSourceId {
258        /// Affected selection.
259        selection: ProfileSelection,
260        /// Repeated source id.
261        source_id: &'static str,
262    },
263    /// A source references a setting absent from the record.
264    #[error("V2 source {source_id} references unknown setting {setting:?} in {selection:?}")]
265    UnknownSourceSetting {
266        /// Affected selection.
267        selection: ProfileSelection,
268        /// Source id.
269        source_id: &'static str,
270        /// Missing setting id.
271        setting: SettingIdV2,
272    },
273    /// A source references an absent or non-known fact.
274    #[error("V2 source {source_id} references unavailable fact {fact:?} in {selection:?}")]
275    UnknownSourceFact {
276        /// Affected selection.
277        selection: ProfileSelection,
278        /// Source id.
279        source_id: &'static str,
280        /// Missing or non-known fact.
281        fact: animsmith_core::engine_contract::EngineFactIdV2,
282    },
283    /// Accepted inputs have no primary authority.
284    #[error("V2 accepted inputs have no source reference in {selection:?}")]
285    UnreferencedAcceptedInputs {
286        /// Affected selection.
287        selection: ProfileSelection,
288    },
289    /// A known fact has no supporting primary authority.
290    #[error("V2 fact {fact:?} has no source reference in {selection:?}")]
291    UnreferencedFact {
292        /// Affected selection.
293        selection: ProfileSelection,
294        /// Unsupported known fact.
295        fact: animsmith_core::engine_contract::EngineFactIdV2,
296    },
297    /// A descriptor has no supporting primary authority.
298    #[error("V2 setting {setting:?} has no source reference in {selection:?}")]
299    UnreferencedSetting {
300        /// Affected selection.
301        selection: ProfileSelection,
302        /// Unsupported descriptor.
303        setting: SettingIdV2,
304    },
305}
306
307/// Validate all author-owned registry declarations and cross references.
308///
309/// This checks tuple and URN uniqueness, complete fact inventories, the
310/// canonical accepted-input fact, descriptor/default consistency, and every
311/// source's stable fact/setting references.
312///
313/// # Errors
314///
315/// Returns a typed invariant error naming the affected profile and field.
316pub fn validate_registry_v1() -> Result<(), RegistryValidationError> {
317    validate_profiles(profiles_v1())
318}
319
320fn validate_profiles(profiles: &[EngineProfile]) -> Result<(), RegistryValidationError> {
321    if profiles.len() != 5 {
322        return Err(RegistryValidationError::ProfileCount {
323            found: profiles.len(),
324        });
325    }
326    let mut selections = BTreeSet::new();
327    let mut urns = BTreeSet::new();
328    for profile in profiles {
329        if !selections.insert(profile.selection.clone()) {
330            return Err(RegistryValidationError::DuplicateSelection {
331                selection: profile.selection.clone(),
332            });
333        }
334        if !urns.insert(profile.fact_bundle_urn) {
335            return Err(RegistryValidationError::DuplicateFactBundleUrn {
336                urn: profile.fact_bundle_urn,
337            });
338        }
339        let fact_ids: BTreeSet<_> = profile.facts.iter().map(ProfileFact::id).collect();
340        if fact_ids.len() != profile.facts.len()
341            || fact_ids != ALL_FACT_IDS.into_iter().collect::<BTreeSet<_>>()
342        {
343            return Err(RegistryValidationError::InvalidFactInventory {
344                selection: profile.selection.clone(),
345            });
346        }
347        let accepted_inputs = profile.accepted_inputs();
348        if accepted_inputs.is_empty()
349            || !accepted_inputs.windows(2).all(|pair| {
350                crate::canonical::format_name(pair[0]) < crate::canonical::format_name(pair[1])
351            })
352        {
353            return Err(RegistryValidationError::InvalidAcceptedInputFact {
354                selection: profile.selection.clone(),
355            });
356        }
357        let setting_ids: BTreeSet<_> = profile.settings.iter().map(SettingDescriptor::id).collect();
358        if setting_ids.len() != profile.settings.len() {
359            return Err(RegistryValidationError::DuplicateSettingDescriptor {
360                selection: profile.selection.clone(),
361            });
362        }
363        for descriptor in &profile.settings {
364            let valid_default = matches!(
365                (descriptor.applicability(), descriptor.default_status()),
366                (
367                    SettingApplicability::Applicable,
368                    DefaultStatus::RequiredWithoutDefault
369                ) | (
370                    SettingApplicability::NotApplicable,
371                    DefaultStatus::NotApplicable
372                )
373            );
374            if !valid_default {
375                return Err(RegistryValidationError::InvalidDescriptorDefault {
376                    selection: profile.selection.clone(),
377                    setting: descriptor.id(),
378                });
379            }
380        }
381        let mut source_ids = BTreeSet::new();
382        for source in &profile.sources {
383            if !source_ids.insert(source.id()) {
384                return Err(RegistryValidationError::DuplicateSourceId {
385                    selection: profile.selection.clone(),
386                    source_id: source.id(),
387                });
388            }
389            for fact in source.supported_facts() {
390                if !fact_ids.contains(fact) {
391                    return Err(RegistryValidationError::UnknownSourceFact {
392                        selection: profile.selection.clone(),
393                        source_id: source.id(),
394                        fact: *fact,
395                    });
396                }
397                if !matches!(
398                    profile.fact(*fact).map(ProfileFact::state),
399                    Some(FactState::Known(_))
400                ) {
401                    return Err(RegistryValidationError::SourceReferencesNonKnownFact {
402                        selection: profile.selection.clone(),
403                        source_id: source.id(),
404                        fact: *fact,
405                    });
406                }
407            }
408            for setting in source.supported_settings() {
409                if !setting_ids.contains(setting) {
410                    return Err(RegistryValidationError::UnknownSourceSetting {
411                        selection: profile.selection.clone(),
412                        source_id: source.id(),
413                        setting: *setting,
414                    });
415                }
416            }
417        }
418        for fact in &profile.facts {
419            if matches!(fact.state(), FactState::Known(_))
420                && !profile
421                    .sources
422                    .iter()
423                    .any(|source| source.supported_facts().contains(&fact.id()))
424            {
425                return Err(RegistryValidationError::UnreferencedKnownFact {
426                    selection: profile.selection.clone(),
427                    fact: fact.id(),
428                });
429            }
430        }
431        for descriptor in &profile.settings {
432            if !profile
433                .sources
434                .iter()
435                .any(|source| source.supported_settings().contains(&descriptor.id()))
436            {
437                return Err(RegistryValidationError::UnreferencedSetting {
438                    selection: profile.selection.clone(),
439                    setting: descriptor.id(),
440                });
441            }
442        }
443        if facts_identity(profile) != profile.facts_identity {
444            return Err(RegistryValidationError::FactsIdentityMismatch {
445                selection: profile.selection.clone(),
446            });
447        }
448    }
449    Ok(())
450}
451
452fn unity_generic() -> EngineProfile {
453    let accepted = vec![SourceFormatV1::Fbx];
454    let facts = base_facts(
455        &accepted,
456        vec![
457            (
458                FactId::UnitConversionControl,
459                FactValue::ConversionControl(ConversionControl::ProfileSetting(
460                    SettingId::ConvertUnits,
461                )),
462            ),
463            (
464                FactId::AxisConversionControl,
465                FactValue::ConversionControl(ConversionControl::ProfileSetting(
466                    SettingId::BakeAxisConversion,
467                )),
468            ),
469            (
470                FactId::RootMotionAddressability,
471                FactValue::RootMotionAddressability(
472                    RootMotionAddressability::ExactSourceTransformPath,
473                ),
474            ),
475        ],
476    );
477    finish_profile(
478        ProfileSelection::new("unity-generic", 1, "6000.3", "fbx-model-importer"),
479        "urn:animsmith:engine-profile:unity-generic:1",
480        facts,
481        unity_settings(true),
482        unity_sources(true),
483    )
484}
485
486fn unity_humanoid() -> EngineProfile {
487    let accepted = vec![SourceFormatV1::Fbx];
488    let facts = base_facts(
489        &accepted,
490        vec![
491            (
492                FactId::UnitConversionControl,
493                FactValue::ConversionControl(ConversionControl::ProfileSetting(
494                    SettingId::ConvertUnits,
495                )),
496            ),
497            (
498                FactId::AxisConversionControl,
499                FactValue::ConversionControl(ConversionControl::ProfileSetting(
500                    SettingId::BakeAxisConversion,
501                )),
502            ),
503            (
504                FactId::RootMotionAddressability,
505                FactValue::RootMotionAddressability(RootMotionAddressability::HumanoidAvatarBody),
506            ),
507        ],
508    );
509    finish_profile(
510        ProfileSelection::new("unity-humanoid", 1, "6000.3", "fbx-model-importer"),
511        "urn:animsmith:engine-profile:unity-humanoid:1",
512        facts,
513        unity_settings(false),
514        unity_sources(false),
515    )
516}
517
518fn unreal() -> EngineProfile {
519    let accepted = vec![SourceFormatV1::Fbx];
520    let facts = base_facts(
521        &accepted,
522        vec![
523            (
524                FactId::TargetCoordinateBasis,
525                FactValue::CoordinateBasis(CoordinateBasis {
526                    handedness: Handedness::Left,
527                    up_axis: UpAxis::Z,
528                    forward_axis: ForwardAxis::PositiveX,
529                }),
530            ),
531            (
532                FactId::TargetLinearUnit,
533                FactValue::LinearUnit(LinearUnit::Centimetre),
534            ),
535            (
536                FactId::UnitConversionControl,
537                FactValue::ConversionControl(ConversionControl::ImporterOption),
538            ),
539            (
540                FactId::AxisConversionControl,
541                FactValue::ConversionControl(ConversionControl::ImporterOption),
542            ),
543            (FactId::WholeEndFrameRequired, FactValue::Boolean(true)),
544        ],
545    );
546    finish_profile(
547        ProfileSelection::new("unreal", 1, "5.8", "fbx-importer"),
548        "urn:animsmith:engine-profile:unreal:1",
549        facts,
550        vec![],
551        vec![
552            source(
553                "unreal-animation-sequences-5.8",
554                "5.8",
555                "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
556                &[FactId::WholeEndFrameRequired],
557                &[],
558            ),
559            source(
560                "unreal-coordinate-system-5.8",
561                "5.8",
562                "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
563                &[FactId::TargetCoordinateBasis],
564                &[],
565            ),
566            source(
567                "unreal-fbx-import-options-5.8",
568                "5.8",
569                "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
570                &[
571                    FactId::AcceptedInputs,
572                    FactId::UnitConversionControl,
573                    FactId::AxisConversionControl,
574                ],
575                &[],
576            ),
577            source(
578                "unreal-units-5.8",
579                "5.8",
580                "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
581                &[FactId::TargetLinearUnit],
582                &[],
583            ),
584        ],
585    )
586}
587
588fn godot() -> EngineProfile {
589    let accepted = vec![
590        SourceFormatV1::GltfJson,
591        SourceFormatV1::Glb,
592        SourceFormatV1::Fbx,
593    ];
594    let facts = base_facts(&accepted, vec![]);
595    finish_profile(
596        ProfileSelection::new("godot", 1, "4.7", "resource-importer-scene"),
597        "urn:animsmith:engine-profile:godot:1",
598        facts,
599        vec![],
600        vec![source(
601            "godot-resource-importer-scene-4.7",
602            "4.7",
603            "https://docs.godotengine.org/en/4.7/classes/class_resourceimporterscene.html",
604            &[FactId::AcceptedInputs],
605            &[],
606        )],
607    )
608}
609
610fn bevy() -> EngineProfile {
611    let accepted = vec![SourceFormatV1::GltfJson, SourceFormatV1::Glb];
612    let facts = base_facts(
613        &accepted,
614        vec![
615            (
616                FactId::AnimationAddressability,
617                FactValue::AnimationAddressability(AnimationAddressability::GltfAssetLabel),
618            ),
619            (
620                FactId::AnimationTargetAddressability,
621                FactValue::TargetAddressability(TargetAddressability::NamePathDerivedId),
622            ),
623        ],
624    );
625    finish_profile(
626        ProfileSelection::new("bevy", 1, "0.19.0", "gltf-asset-loader"),
627        "urn:animsmith:engine-profile:bevy:1",
628        facts,
629        vec![],
630        vec![
631            source(
632                "bevy-animation-target-id-0.19.0",
633                "0.19.0",
634                "https://docs.rs/bevy/0.19.0/bevy/animation/struct.AnimationTargetId.html",
635                &[FactId::AnimationTargetAddressability],
636                &[],
637            ),
638            source(
639                "bevy-gltf-asset-label-0.19.0",
640                "0.19.0",
641                "https://docs.rs/bevy/0.19.0/bevy/gltf/enum.GltfAssetLabel.html",
642                &[FactId::AcceptedInputs, FactId::AnimationAddressability],
643                &[],
644            ),
645            source(
646                "khronos-gltf-2.0",
647                "2.0",
648                "https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html",
649                &[FactId::AcceptedInputs],
650                &[],
651            ),
652        ],
653    )
654}
655
656/// Frozen Unity Generic revision-2 profile for root-motion routing.
657///
658/// The record deliberately contains only the importer controls required by
659/// the first bounded root-motion slice. Revision 1 remains the historical V1
660/// profile and is not amended by this sibling record.
661fn unity_generic_v2() -> EngineProfileV2 {
662    use SettingDefaultV2::RequiredExplicit;
663    use SettingDomainV2::{
664        AnimationType, AvatarSetup, BakeOrExtract, Boolean, SourceTransformPath,
665    };
666    use SettingIdV2::{
667        AnimationType as AnimationTypeId, AvatarSetup as AvatarSetupId, ImportAnimation,
668        RootMotionSource, RootPositionXz, RootPositionY, RootRotation,
669    };
670    use animsmith_core::engine_contract::{
671        EngineFactIdV2, EngineFactStateV2, EngineFactValueV2, EngineProfileFactV2,
672        EngineRootMotionAddressabilityV1,
673    };
674
675    let mut settings = vec![
676        SettingDescriptorV2::new(
677            AnimationTypeId,
678            SettingScope::Document,
679            AnimationType,
680            RequiredExplicit,
681        ),
682        SettingDescriptorV2::new(
683            AvatarSetupId,
684            SettingScope::Document,
685            AvatarSetup,
686            RequiredExplicit,
687        ),
688        SettingDescriptorV2::new(
689            ImportAnimation,
690            SettingScope::Document,
691            Boolean,
692            RequiredExplicit,
693        ),
694        SettingDescriptorV2::new(
695            RootMotionSource,
696            SettingScope::Document,
697            SourceTransformPath,
698            RequiredExplicit,
699        ),
700        SettingDescriptorV2::new(
701            RootRotation,
702            SettingScope::Clip,
703            BakeOrExtract,
704            RequiredExplicit,
705        ),
706        SettingDescriptorV2::new(
707            RootPositionY,
708            SettingScope::Clip,
709            BakeOrExtract,
710            RequiredExplicit,
711        ),
712        SettingDescriptorV2::new(
713            RootPositionXz,
714            SettingScope::Clip,
715            BakeOrExtract,
716            RequiredExplicit,
717        ),
718    ];
719    settings.sort_by_key(|descriptor| descriptor.id().as_str());
720
721    let facts = vec![
722        EngineProfileFactV2::new(
723            EngineFactIdV2::AcceptedInputs,
724            EngineFactStateV2::Known(EngineFactValueV2::AcceptedFormats(vec![
725                SourceFormatV1::Fbx,
726            ])),
727        ),
728        EngineProfileFactV2::new(
729            EngineFactIdV2::ApplicationWorldUnitPolicy,
730            EngineFactStateV2::Unknown,
731        ),
732        EngineProfileFactV2::new(
733            EngineFactIdV2::ImportSettingProjection,
734            EngineFactStateV2::Unknown,
735        ),
736        EngineProfileFactV2::new(
737            EngineFactIdV2::ImporterScaleConversion,
738            EngineFactStateV2::Unknown,
739        ),
740        EngineProfileFactV2::new(
741            EngineFactIdV2::PhysicalDimensionsPreserved,
742            EngineFactStateV2::Unknown,
743        ),
744        EngineProfileFactV2::new(
745            EngineFactIdV2::ResultingTransformScale,
746            EngineFactStateV2::Unknown,
747        ),
748        EngineProfileFactV2::new(
749            EngineFactIdV2::RootMotionAddressability,
750            EngineFactStateV2::Known(EngineFactValueV2::RootMotionAddressability(
751                EngineRootMotionAddressabilityV1::ExactSourceTransformPath,
752            )),
753        ),
754        EngineProfileFactV2::new(
755            EngineFactIdV2::SourceImportDisposition,
756            EngineFactStateV2::Unknown,
757        ),
758        EngineProfileFactV2::new(
759            EngineFactIdV2::SourceToTargetUnitMapping,
760            EngineFactStateV2::Unknown,
761        ),
762        EngineProfileFactV2::new(EngineFactIdV2::TargetLinearUnit, EngineFactStateV2::Unknown),
763    ];
764
765    let mut sources = vec![
766        PrimarySourceV2 {
767            id: "unity-fbx-model-importer-6000.3",
768            target_version: "6000.3",
769            url: "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Model.html",
770            verified_on: UNITY_GENERIC_V2_VERIFIED_ON,
771            supported_facts: vec![EngineFactIdV2::AcceptedInputs],
772            supported_settings: vec![AnimationTypeId, AvatarSetupId, ImportAnimation],
773        },
774        PrimarySourceV2 {
775            id: "unity-fbx-animation-clip-6000.3",
776            target_version: "6000.3",
777            url: "https://docs.unity3d.com/6000.3/Documentation/Manual/class-AnimationClip.html",
778            verified_on: UNITY_GENERIC_V2_VERIFIED_ON,
779            supported_facts: vec![],
780            supported_settings: vec![RootRotation, RootPositionY, RootPositionXz],
781        },
782        PrimarySourceV2 {
783            id: "unity-fbx-motion-node-6000.3",
784            target_version: "6000.3",
785            url: "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporter-motionNodeName.html",
786            verified_on: UNITY_GENERIC_V2_VERIFIED_ON,
787            supported_facts: vec![EngineFactIdV2::RootMotionAddressability],
788            supported_settings: vec![RootMotionSource],
789        },
790    ];
791    for source in &mut sources {
792        source.supported_facts.sort_by_key(|fact| fact.as_str());
793        source
794            .supported_settings
795            .sort_by_key(|setting| setting.as_str());
796    }
797    sources.sort_by_key(|source| source.id);
798
799    EngineProfileV2 {
800        selection: ProfileSelection::new("unity-generic", 2, "6000.3", "fbx-model-importer"),
801        profile_urn: "urn:animsmith:engine-profile:unity-generic:2",
802        accepted_inputs: vec![SourceFormatV1::Fbx],
803        facts,
804        settings,
805        sources,
806    }
807}
808
809/// Frozen Unreal revision-2 profile for document-level FBX sample-rate advice.
810fn unreal_v2() -> EngineProfileV2 {
811    use SettingDefaultV2::RequiredExplicit;
812    use SettingDomainV2::SampleRate;
813    use SettingIdV2::SampleRate as SampleRateId;
814    use animsmith_core::engine_contract::EngineFactIdV2;
815
816    let settings = vec![SettingDescriptorV2::new(
817        SampleRateId,
818        SettingScope::Document,
819        SampleRate,
820        RequiredExplicit,
821    )];
822    let sources = vec![PrimarySourceV2 {
823        id: "unreal-fbx-import-options-5.8",
824        target_version: "5.8",
825        url: "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
826        verified_on: IMPORT_ADVICE_V2_VERIFIED_ON,
827        supported_facts: vec![
828            EngineFactIdV2::AcceptedInputs,
829            EngineFactIdV2::ImportSettingProjection,
830        ],
831        supported_settings: vec![SampleRateId],
832    }];
833
834    EngineProfileV2 {
835        selection: ProfileSelection::new("unreal", 2, "5.8", "fbx-importer"),
836        profile_urn: "urn:animsmith:engine-profile:unreal:2",
837        accepted_inputs: vec![SourceFormatV1::Fbx],
838        facts: document_advice_facts_v2(vec![SourceFormatV1::Fbx], "unreal_fbx_import_data"),
839        settings,
840        sources,
841    }
842}
843
844/// Frozen Godot revision-2 profile for document-level scene-import advice.
845fn godot_v2() -> EngineProfileV2 {
846    use SettingDefaultV2::Verified;
847    use SettingDomainV2::{Boolean, PositiveInteger};
848    use SettingIdV2::{AnimationFps, AnimationTrimming};
849    use animsmith_core::engine_contract::EngineFactIdV2;
850
851    let mut settings = vec![
852        SettingDescriptorV2::new(
853            AnimationFps,
854            SettingScope::Document,
855            PositiveInteger,
856            Verified(SettingValueV2::PositiveInteger(30)),
857        ),
858        SettingDescriptorV2::new(
859            AnimationTrimming,
860            SettingScope::Document,
861            Boolean,
862            Verified(SettingValueV2::Boolean(false)),
863        ),
864    ];
865    settings.sort_by_key(|descriptor| descriptor.id().as_str());
866
867    let sources = vec![PrimarySourceV2 {
868        id: "godot-resource-importer-scene-4.7",
869        target_version: "4.7",
870        url: "https://docs.godotengine.org/en/4.7/classes/class_resourceimporterscene.html",
871        verified_on: IMPORT_ADVICE_V2_VERIFIED_ON,
872        supported_facts: vec![
873            EngineFactIdV2::AcceptedInputs,
874            EngineFactIdV2::ImportSettingProjection,
875        ],
876        supported_settings: vec![AnimationFps, AnimationTrimming],
877    }];
878
879    let accepted_inputs = vec![SourceFormatV1::Glb, SourceFormatV1::GltfJson];
880    EngineProfileV2 {
881        selection: ProfileSelection::new("godot", 2, "4.7", "resource-importer-scene"),
882        profile_urn: "urn:animsmith:engine-profile:godot:2",
883        facts: document_advice_facts_v2(accepted_inputs.clone(), "godot_params"),
884        accepted_inputs,
885        settings,
886        sources,
887    }
888}
889
890fn document_advice_facts_v2(
891    accepted_inputs: Vec<SourceFormatV1>,
892    import_setting_projection: &'static str,
893) -> Vec<animsmith_core::engine_contract::EngineProfileFactV2> {
894    use animsmith_core::engine_contract::{
895        EngineFactIdV2, EngineFactStateV2, EngineFactValueV2, EngineProfileFactV2,
896    };
897
898    vec![
899        EngineProfileFactV2::new(
900            EngineFactIdV2::AcceptedInputs,
901            EngineFactStateV2::Known(EngineFactValueV2::AcceptedFormats(accepted_inputs)),
902        ),
903        EngineProfileFactV2::new(
904            EngineFactIdV2::ApplicationWorldUnitPolicy,
905            EngineFactStateV2::Unknown,
906        ),
907        EngineProfileFactV2::new(
908            EngineFactIdV2::ImportSettingProjection,
909            EngineFactStateV2::Known(EngineFactValueV2::Token(import_setting_projection.into())),
910        ),
911        EngineProfileFactV2::new(
912            EngineFactIdV2::ImporterScaleConversion,
913            EngineFactStateV2::Unknown,
914        ),
915        EngineProfileFactV2::new(
916            EngineFactIdV2::PhysicalDimensionsPreserved,
917            EngineFactStateV2::Unknown,
918        ),
919        EngineProfileFactV2::new(
920            EngineFactIdV2::ResultingTransformScale,
921            EngineFactStateV2::Unknown,
922        ),
923        EngineProfileFactV2::new(
924            EngineFactIdV2::RootMotionAddressability,
925            EngineFactStateV2::Unknown,
926        ),
927        EngineProfileFactV2::new(
928            EngineFactIdV2::SourceImportDisposition,
929            EngineFactStateV2::Unknown,
930        ),
931        EngineProfileFactV2::new(
932            EngineFactIdV2::SourceToTargetUnitMapping,
933            EngineFactStateV2::Unknown,
934        ),
935        EngineProfileFactV2::new(EngineFactIdV2::TargetLinearUnit, EngineFactStateV2::Unknown),
936    ]
937}
938
939fn bevy_v2() -> EngineProfileV2 {
940    use SettingDefaultV2::{RequiredExplicit, Verified};
941    use SettingDomainV2::{Boolean, HandlerEnvironment, LoadMeshesState};
942    use SettingIdV2::{
943        BevyAnimationFeature, ExtensionHandlerEnvironment, LoadAnimations, LoadMeshes,
944        RotateMeshes, RotateSceneEntity,
945    };
946    use animsmith_core::engine_contract::{
947        EngineFactIdV2, EngineFactStateV2, EngineFactValueV2, EngineLinearUnitV2,
948        EngineProfileFactV2, ReducedRatioV1,
949    };
950
951    let mut settings = vec![
952        SettingDescriptorV2::new(
953            RotateSceneEntity,
954            SettingScope::Document,
955            Boolean,
956            Verified(SettingValueV2::Boolean(false)),
957        ),
958        SettingDescriptorV2::new(
959            RotateMeshes,
960            SettingScope::Document,
961            Boolean,
962            Verified(SettingValueV2::Boolean(false)),
963        ),
964        SettingDescriptorV2::new(
965            LoadMeshes,
966            SettingScope::Document,
967            LoadMeshesState,
968            Verified(SettingValueV2::LoadMeshesState(
969                crate::BevyLoadMeshesStateV2::Nonempty,
970            )),
971        ),
972        SettingDescriptorV2::new(
973            ExtensionHandlerEnvironment,
974            SettingScope::Document,
975            HandlerEnvironment,
976            RequiredExplicit,
977        ),
978        // These two settings are frozen now so #483 can consume the same
979        // profile/settings contract without another incompatible profile
980        // revision. This ticket attaches no prediction behavior to them.
981        SettingDescriptorV2::new(
982            BevyAnimationFeature,
983            SettingScope::Document,
984            Boolean,
985            RequiredExplicit,
986        ),
987        SettingDescriptorV2::new(
988            LoadAnimations,
989            SettingScope::Document,
990            Boolean,
991            Verified(SettingValueV2::Boolean(true)),
992        ),
993    ];
994    settings.sort_by_key(|descriptor| descriptor.id().as_str());
995
996    let facts = vec![
997        EngineProfileFactV2::new(
998            EngineFactIdV2::AcceptedInputs,
999            EngineFactStateV2::Known(EngineFactValueV2::AcceptedFormats(vec![
1000                SourceFormatV1::Glb,
1001                SourceFormatV1::GltfJson,
1002            ])),
1003        ),
1004        EngineProfileFactV2::new(
1005            EngineFactIdV2::ApplicationWorldUnitPolicy,
1006            EngineFactStateV2::Known(EngineFactValueV2::Boolean(false)),
1007        ),
1008        EngineProfileFactV2::new(
1009            EngineFactIdV2::ImporterScaleConversion,
1010            EngineFactStateV2::Known(EngineFactValueV2::Token("none".into())),
1011        ),
1012        EngineProfileFactV2::new(
1013            EngineFactIdV2::ImportSettingProjection,
1014            EngineFactStateV2::Unknown,
1015        ),
1016        EngineProfileFactV2::new(
1017            EngineFactIdV2::PhysicalDimensionsPreserved,
1018            EngineFactStateV2::Known(EngineFactValueV2::Boolean(true)),
1019        ),
1020        EngineProfileFactV2::new(
1021            EngineFactIdV2::ResultingTransformScale,
1022            EngineFactStateV2::Known(EngineFactValueV2::Token(
1023                "loader_entities_unit_orthonormal_trs_nodes_passthrough_matrix_nodes_decomposed"
1024                    .into(),
1025            )),
1026        ),
1027        EngineProfileFactV2::new(
1028            EngineFactIdV2::RootMotionAddressability,
1029            EngineFactStateV2::Unknown,
1030        ),
1031        EngineProfileFactV2::new(
1032            EngineFactIdV2::SourceImportDisposition,
1033            EngineFactStateV2::Unknown,
1034        ),
1035        EngineProfileFactV2::new(
1036            EngineFactIdV2::SourceToTargetUnitMapping,
1037            EngineFactStateV2::Known(EngineFactValueV2::UnitRatio(
1038                ReducedRatioV1::new(1, 1).expect("one-to-one is a reduced positive ratio"),
1039            )),
1040        ),
1041        EngineProfileFactV2::new(
1042            EngineFactIdV2::TargetLinearUnit,
1043            EngineFactStateV2::Known(EngineFactValueV2::LinearUnit(
1044                EngineLinearUnitV2::EngineWorldLengthUnit,
1045            )),
1046        ),
1047    ];
1048
1049    let pinned = |path: &'static str| -> &'static str {
1050        match path {
1051            "loader" => {
1052                "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_gltf/src/loader/mod.rs"
1053            }
1054            "coordinates" => {
1055                "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_gltf/src/convert_coordinates.rs"
1056            }
1057            "handlers" => {
1058                "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_gltf/src/loader/extensions/mod.rs"
1059            }
1060            "pbr" => {
1061                "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_pbr/src/gltf.rs"
1062            }
1063            "render_asset" => {
1064                "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_asset/src/render_asset.rs"
1065            }
1066            "cargo" => {
1067                "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/Cargo.toml"
1068            }
1069            _ => unreachable!("closed Bevy source path"),
1070        }
1071    };
1072    let mut sources = vec![
1073        PrimarySourceV2 {
1074            id: "bevy-gltf-loader-0.19.0-c6f634ca",
1075            target_version: BEVY_0_19_COMMIT,
1076            url: pinned("loader"),
1077            verified_on: BEVY_V2_VERIFIED_ON,
1078            supported_facts: vec![
1079                EngineFactIdV2::AcceptedInputs,
1080                EngineFactIdV2::ApplicationWorldUnitPolicy,
1081                EngineFactIdV2::ImporterScaleConversion,
1082                EngineFactIdV2::PhysicalDimensionsPreserved,
1083                EngineFactIdV2::ResultingTransformScale,
1084                EngineFactIdV2::SourceToTargetUnitMapping,
1085                EngineFactIdV2::TargetLinearUnit,
1086            ],
1087            supported_settings: vec![LoadAnimations, LoadMeshes, RotateMeshes, RotateSceneEntity],
1088        },
1089        PrimarySourceV2 {
1090            id: "bevy-gltf-coordinate-conversion-0.19.0-c6f634ca",
1091            target_version: BEVY_0_19_COMMIT,
1092            url: pinned("coordinates"),
1093            verified_on: BEVY_V2_VERIFIED_ON,
1094            supported_facts: vec![
1095                EngineFactIdV2::ImporterScaleConversion,
1096                EngineFactIdV2::PhysicalDimensionsPreserved,
1097                EngineFactIdV2::ResultingTransformScale,
1098                EngineFactIdV2::SourceToTargetUnitMapping,
1099            ],
1100            supported_settings: vec![RotateMeshes, RotateSceneEntity],
1101        },
1102        PrimarySourceV2 {
1103            id: "bevy-gltf-extension-registry-0.19.0-c6f634ca",
1104            target_version: BEVY_0_19_COMMIT,
1105            url: pinned("handlers"),
1106            verified_on: BEVY_V2_VERIFIED_ON,
1107            supported_facts: vec![],
1108            supported_settings: vec![ExtensionHandlerEnvironment],
1109        },
1110        PrimarySourceV2 {
1111            id: "bevy-pbr-gltf-handler-0.19.0-c6f634ca",
1112            target_version: BEVY_0_19_COMMIT,
1113            url: pinned("pbr"),
1114            verified_on: BEVY_V2_VERIFIED_ON,
1115            supported_facts: vec![],
1116            supported_settings: vec![ExtensionHandlerEnvironment],
1117        },
1118        PrimarySourceV2 {
1119            id: "bevy-render-asset-usages-0.19.0-c6f634ca",
1120            target_version: BEVY_0_19_COMMIT,
1121            url: pinned("render_asset"),
1122            verified_on: BEVY_V2_VERIFIED_ON,
1123            supported_facts: vec![],
1124            supported_settings: vec![LoadMeshes],
1125        },
1126        PrimarySourceV2 {
1127            id: "bevy-feature-manifest-0.19.0-c6f634ca",
1128            target_version: BEVY_0_19_COMMIT,
1129            url: pinned("cargo"),
1130            verified_on: BEVY_V2_VERIFIED_ON,
1131            supported_facts: vec![],
1132            supported_settings: vec![BevyAnimationFeature],
1133        },
1134        PrimarySourceV2 {
1135            id: "khronos-gltf-2.0-coordinate-units",
1136            target_version: "2.0",
1137            url: "https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units",
1138            verified_on: BEVY_V2_VERIFIED_ON,
1139            supported_facts: vec![
1140                EngineFactIdV2::AcceptedInputs,
1141                EngineFactIdV2::PhysicalDimensionsPreserved,
1142                EngineFactIdV2::SourceToTargetUnitMapping,
1143            ],
1144            supported_settings: vec![],
1145        },
1146    ];
1147    for source in &mut sources {
1148        source.supported_facts.sort_by_key(|fact| fact.as_str());
1149        source
1150            .supported_settings
1151            .sort_by_key(|setting| setting.as_str());
1152    }
1153    sources.sort_by_key(|source| source.id());
1154
1155    EngineProfileV2 {
1156        selection: ProfileSelection::new("bevy", 2, "0.19.0", "gltf-asset-loader"),
1157        profile_urn: "urn:animsmith:engine-profile:bevy:2",
1158        accepted_inputs: vec![SourceFormatV1::Glb, SourceFormatV1::GltfJson],
1159        facts,
1160        settings,
1161        sources,
1162    }
1163}
1164
1165/// The successor Bevy record proves only the two materialized animation-loading
1166/// gates.  Revision 2 remains byte-for-byte immutable: its aggregate source
1167/// disposition stays unknown, so it cannot accidentally gain this later rule.
1168fn bevy_v3() -> EngineProfileV2 {
1169    use animsmith_core::engine_contract::{EngineFactIdV2, EngineFactStateV2, EngineFactValueV2};
1170
1171    let mut profile = bevy_v2();
1172    profile.selection = ProfileSelection::new("bevy", 3, "0.19.0", "gltf-asset-loader");
1173    profile.profile_urn = "urn:animsmith:engine-profile:bevy:3";
1174    let disposition = profile
1175        .facts
1176        .iter_mut()
1177        .find(|fact| fact.id() == EngineFactIdV2::SourceImportDisposition)
1178        .expect("every V2 profile has source import disposition");
1179    *disposition = animsmith_core::engine_contract::EngineProfileFactV2::new(
1180        EngineFactIdV2::SourceImportDisposition,
1181        EngineFactStateV2::Known(EngineFactValueV2::Token("materialized_import_gates".into())),
1182    );
1183    let loader = profile
1184        .sources
1185        .iter_mut()
1186        .find(|source| source.id == "bevy-gltf-loader-0.19.0-c6f634ca")
1187        .expect("Bevy V2 loader source exists");
1188    loader
1189        .supported_facts
1190        .push(EngineFactIdV2::SourceImportDisposition);
1191    loader.supported_facts.sort_by_key(|fact| fact.as_str());
1192    profile
1193}
1194
1195fn base_facts(accepted: &[SourceFormatV1], known: Vec<(FactId, FactValue)>) -> Vec<ProfileFact> {
1196    let mut accepted = accepted.to_vec();
1197    accepted.sort_by_key(|format| crate::canonical::format_name(*format));
1198    let known: std::collections::BTreeMap<_, _> = known.into_iter().collect();
1199    ALL_FACT_IDS
1200        .into_iter()
1201        .map(|id| {
1202            let state = if id == FactId::AcceptedInputs {
1203                FactState::Known(FactValue::AcceptedFormats(accepted.clone()))
1204            } else if let Some(value) = known.get(&id) {
1205                FactState::Known(value.clone())
1206            } else {
1207                FactState::Unknown
1208            };
1209            ProfileFact::new(id, state)
1210        })
1211        .collect()
1212}
1213
1214fn unity_settings(generic: bool) -> Vec<SettingDescriptor> {
1215    use DefaultStatus::{NotApplicable, RequiredWithoutDefault};
1216    use SettingApplicability::{Applicable, NotApplicable as DoesNotApply};
1217    vec![
1218        SettingDescriptor::new(
1219            SettingId::ConvertUnits,
1220            SettingScope::Document,
1221            SettingDomain::Boolean,
1222            Applicable,
1223            RequiredWithoutDefault,
1224        ),
1225        SettingDescriptor::new(
1226            SettingId::BakeAxisConversion,
1227            SettingScope::Document,
1228            SettingDomain::Boolean,
1229            Applicable,
1230            RequiredWithoutDefault,
1231        ),
1232        SettingDescriptor::new(
1233            SettingId::RootMotionSource,
1234            SettingScope::Document,
1235            SettingDomain::SourceTransformPath,
1236            if generic { Applicable } else { DoesNotApply },
1237            if generic {
1238                RequiredWithoutDefault
1239            } else {
1240                NotApplicable
1241            },
1242        ),
1243        SettingDescriptor::new(
1244            SettingId::RootRotation,
1245            SettingScope::Clip,
1246            SettingDomain::BakeOrExtract,
1247            Applicable,
1248            RequiredWithoutDefault,
1249        ),
1250        SettingDescriptor::new(
1251            SettingId::RootPositionY,
1252            SettingScope::Clip,
1253            SettingDomain::BakeOrExtract,
1254            Applicable,
1255            RequiredWithoutDefault,
1256        ),
1257        SettingDescriptor::new(
1258            SettingId::RootPositionXz,
1259            SettingScope::Clip,
1260            SettingDomain::BakeOrExtract,
1261            Applicable,
1262            RequiredWithoutDefault,
1263        ),
1264    ]
1265}
1266
1267fn unity_sources(generic: bool) -> Vec<PrimarySource> {
1268    let mut sources = vec![
1269        source(
1270            "unity-animation-clip-import-6000.3",
1271            "6000.3",
1272            "https://docs.unity3d.com/6000.3/Documentation/Manual/class-AnimationClip.html",
1273            &[],
1274            &[
1275                SettingId::RootRotation,
1276                SettingId::RootPositionY,
1277                SettingId::RootPositionXz,
1278            ],
1279        ),
1280        source(
1281            "unity-model-import-6000.3",
1282            "6000.3",
1283            "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Model.html",
1284            &[
1285                FactId::AcceptedInputs,
1286                FactId::UnitConversionControl,
1287                FactId::AxisConversionControl,
1288            ],
1289            &[SettingId::ConvertUnits, SettingId::BakeAxisConversion],
1290        ),
1291        source(
1292            "unity-model-importer-clip-animation-6000.3",
1293            "6000.3",
1294            "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporterClipAnimation.html",
1295            &[],
1296            &[
1297                SettingId::RootRotation,
1298                SettingId::RootPositionY,
1299                SettingId::RootPositionXz,
1300            ],
1301        ),
1302        source(
1303            "unity-rig-import-6000.3",
1304            "6000.3",
1305            "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Rig.html",
1306            &[FactId::RootMotionAddressability],
1307            if generic {
1308                &[]
1309            } else {
1310                &[SettingId::RootMotionSource]
1311            },
1312        ),
1313    ];
1314    if generic {
1315        sources.push(source(
1316            "unity-model-importer-motion-node-name-6000.3",
1317            "6000.3",
1318            "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporter-motionNodeName.html",
1319            &[FactId::RootMotionAddressability],
1320            &[SettingId::RootMotionSource],
1321        ));
1322    }
1323    sources
1324}
1325
1326fn source(
1327    id: &'static str,
1328    target_version: &'static str,
1329    url: &'static str,
1330    facts: &[FactId],
1331    settings: &[SettingId],
1332) -> PrimarySource {
1333    PrimarySource::new(
1334        id,
1335        target_version,
1336        url,
1337        VERIFIED_ON,
1338        facts.to_vec(),
1339        settings.to_vec(),
1340    )
1341}
1342
1343fn finish_profile(
1344    selection: ProfileSelection,
1345    fact_bundle_urn: &'static str,
1346    mut facts: Vec<ProfileFact>,
1347    mut settings: Vec<SettingDescriptor>,
1348    mut sources: Vec<PrimarySource>,
1349) -> EngineProfile {
1350    facts.sort_by_key(|fact| fact.id().as_str());
1351    settings.sort_by_key(|descriptor| descriptor.id().as_str());
1352    sources.sort_by_key(PrimarySource::id);
1353    let mut profile = EngineProfile {
1354        selection,
1355        fact_bundle_urn,
1356        facts,
1357        settings,
1358        sources,
1359        facts_identity: InputIdentity::from_bytes(&[]),
1360    };
1361    profile.facts_identity = facts_identity(&profile);
1362    profile
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368
1369    fn without_fact(source: &PrimarySource, removed: FactId) -> PrimarySource {
1370        PrimarySource::new(
1371            source.id(),
1372            source.target_version(),
1373            source.url(),
1374            source.verified_on(),
1375            source
1376                .supported_facts()
1377                .iter()
1378                .copied()
1379                .filter(|id| *id != removed)
1380                .collect(),
1381            source.supported_settings().to_vec(),
1382        )
1383    }
1384
1385    fn without_setting(source: &PrimarySource, removed: SettingId) -> PrimarySource {
1386        PrimarySource::new(
1387            source.id(),
1388            source.target_version(),
1389            source.url(),
1390            source.verified_on(),
1391            source.supported_facts().to_vec(),
1392            source
1393                .supported_settings()
1394                .iter()
1395                .copied()
1396                .filter(|id| *id != removed)
1397                .collect(),
1398        )
1399    }
1400
1401    #[test]
1402    fn validation_refuses_known_fact_without_source_coverage() {
1403        let mut profiles = profiles_v1().to_vec();
1404        let bevy = profiles
1405            .iter_mut()
1406            .find(|profile| profile.selection.family() == "bevy")
1407            .unwrap();
1408        bevy.sources = bevy
1409            .sources
1410            .iter()
1411            .map(|source| without_fact(source, FactId::AnimationTargetAddressability))
1412            .collect();
1413        assert!(matches!(
1414            validate_profiles(&profiles),
1415            Err(RegistryValidationError::UnreferencedKnownFact {
1416                fact: FactId::AnimationTargetAddressability,
1417                ..
1418            })
1419        ));
1420    }
1421
1422    #[test]
1423    fn validation_refuses_not_applicable_descriptor_without_source_coverage() {
1424        let mut profiles = profiles_v1().to_vec();
1425        let humanoid = profiles
1426            .iter_mut()
1427            .find(|profile| profile.selection.family() == "unity-humanoid")
1428            .unwrap();
1429        humanoid.sources = humanoid
1430            .sources
1431            .iter()
1432            .map(|source| without_setting(source, SettingId::RootMotionSource))
1433            .collect();
1434        assert!(matches!(
1435            validate_profiles(&profiles),
1436            Err(RegistryValidationError::UnreferencedSetting {
1437                setting: SettingId::RootMotionSource,
1438                ..
1439            })
1440        ));
1441    }
1442
1443    #[test]
1444    fn validation_refuses_every_noncanonical_accepted_input_fact_shape() {
1445        for state in [
1446            FactState::Unknown,
1447            FactState::Known(FactValue::AcceptedFormats(vec![])),
1448            FactState::Known(FactValue::AcceptedFormats(vec![
1449                SourceFormatV1::GltfJson,
1450                SourceFormatV1::Fbx,
1451            ])),
1452        ] {
1453            let mut profiles = profiles_v1().to_vec();
1454            let godot = profiles
1455                .iter_mut()
1456                .find(|profile| profile.selection.family() == "godot")
1457                .unwrap();
1458            let accepted = godot
1459                .facts
1460                .iter_mut()
1461                .find(|fact| fact.id() == FactId::AcceptedInputs)
1462                .unwrap();
1463            *accepted = ProfileFact::new(FactId::AcceptedInputs, state);
1464            assert!(matches!(
1465                validate_profiles(&profiles),
1466                Err(RegistryValidationError::InvalidAcceptedInputFact { .. })
1467            ));
1468        }
1469    }
1470}