Skip to main content

animsmith_engine/
root_motion.rs

1//! Exact Unity Generic 6000.3 FBX root-motion routing prediction.
2
3use crate::error::PredictionRuleError;
4use crate::profiles_v2;
5use animsmith_core::engine_contract::{
6    EngineBakeOrExtractV1, EngineSettingIdV2 as SettingIdV2, EngineSettingValueOriginV3,
7    EngineSettingValueV2,
8};
9use animsmith_core::measure::{
10    ClipMeasurements, MeasurementAvailability, RootTrajectorySourceRole,
11};
12use animsmith_core::profile::Role;
13use animsmith_core::{
14    Applicability, Check, CheckCtx, CheckOutput, EngineMachineResultV1, EnginePredictionBasisV4,
15    EnginePredictionFacetV4, EnginePredictionV4, EnginePredictionV6,
16    EngineRootMotionClipMappingStateV1, EngineRootMotionProjectIntentCountV1,
17    EngineRootMotionProjectIntentCoverageV1, EvaluationScope, EvaluationScopeCode, Finding,
18    LoadedSource, MeasurementPointerV1, PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE,
19    PredictionBasisReferenceV1, PredictionBasisReferenceV2, PredictionBasisReferenceV4,
20    PredictionFacetDemandV2, PredictionProvenanceV6, PredictionRuleAllocationV2,
21    PredictionScalarV1, PredictionUnavailableReasonV2, RawAnimationChannelInventoryV1,
22    RawSourceBasisReferenceV1, RawSourceBindingV2, RawSourceDomainV1, RawSourceFieldIdV1,
23    RawSourceKeyV1, RawSourceSetCoverageStateV1, RawTransformPathCoverageReasonV1,
24    RawTransformPathCoverageV1, RawTransformPathResolutionV1, RawTransformPathV1,
25    ResolvedEngineSettingsCoverageStateV2, ResolvedRoles, ResolvedSettingLocationV1,
26    RootMotionAxisV1, RootMotionCompatibilityV1, RootMotionImporterDispositionV1,
27    RootMotionProjectOwnerV1, RootMotionRoutingResultV1, Severity, SourceFormatV1,
28    SourceObservationStateV1,
29};
30use std::collections::BTreeMap;
31
32/// Stable id for exact Unity Generic root-motion routing prediction.
33pub const ENGINE_ROOT_MOTION_CHECK_ID: &str = "engine-root-motion";
34
35const UNITY_FAMILY: &str = "unity-generic";
36const UNITY_PROFILE_REVISION: u32 = 2;
37const UNITY_ENGINE_VERSION: &str = "6000.3";
38const UNITY_IMPORTER: &str = "fbx-model-importer";
39
40const MODEL_IMPORT_SOURCE: &str = "unity-fbx-model-importer-6000.3";
41const CLIP_IMPORT_SOURCE: &str = "unity-fbx-animation-clip-6000.3";
42const MOTION_NODE_SOURCE: &str = "unity-fbx-motion-node-6000.3";
43
44const CLIP_AXIS_SCOPE: &str = "engine-root-motion:clip-axis";
45const INVENTORY_SCOPE: &str = "engine-root-motion:inventory";
46const BUDGET_SCOPE: &str = "engine-root-motion:facet-budget";
47
48const ROOT_SOURCE_MISMATCH_REASON: &str = "animsmith:root_motion_source_not_explicit_root";
49const INTENT_WORK_BUDGET_REASON: &str = "animsmith:root_motion_intent_work_budget_exceeded";
50
51/// Engine-owned check for the exact Unity Generic revision-2 FBX profile.
52///
53/// The indexed measurements are borrowed from the caller so the check consumes
54/// the same measurement rows later published by the output boundary. They must
55/// be in normalized [`animsmith_core::Document::clips`] order.
56pub struct EngineRootMotionCheck<'a> {
57    source: &'a LoadedSource,
58    provenance: Option<&'a PredictionProvenanceV6>,
59    measurements: &'a [ClipMeasurements],
60}
61
62impl<'a> EngineRootMotionCheck<'a> {
63    /// Bind one optional V6 sidecar, explicit role resolution, and the exact
64    /// indexed measurement rows produced for this load.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`PredictionRuleError`] when the provenance does not reproduce
69    /// the supplied source and raw-path inventory, the measurement cardinality
70    /// differs from the normalized document, or the exact Unity tuple carries
71    /// a profile other than the frozen registry record.
72    pub fn new(
73        source: &'a LoadedSource,
74        provenance: Option<&'a PredictionProvenanceV6>,
75        roles: &'a ResolvedRoles,
76        measurements: &'a [ClipMeasurements],
77    ) -> Result<Self, PredictionRuleError> {
78        if measurements.len() != source.document().clips.len() {
79            return Err(PredictionRuleError::SourceProvenanceMismatch);
80        }
81        if let Some(provenance) = provenance {
82            provenance
83                .validate()
84                .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
85            let raw_source = RawSourceBindingV2::from_source(
86                source.source_facts(),
87                source.exact_source_timing(),
88            )
89            .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
90            let base = provenance.base();
91            let resolved_root_bone_index = roles
92                .get(Role::Root)
93                .and_then(|index| u64::try_from(index).ok());
94            if base.base().raw_source() != &raw_source
95                || base.raw_animation_channels()
96                    != &RawAnimationChannelInventoryV1::from_source(source.source_facts())
97                || base.base().dependency_closure() != source.dependency_closure()
98                || !raw_transform_paths_match(source, provenance)
99                || !intent_matches_source(source, provenance)
100                || provenance
101                    .root_motion_project_intent()
102                    .resolved_root_bone_index()
103                    != resolved_root_bone_index
104            {
105                return Err(PredictionRuleError::SourceProvenanceMismatch);
106            }
107            if is_unity_tuple(provenance) && !has_frozen_unity_profile(provenance) {
108                return Err(PredictionRuleError::FrozenProfileMismatch);
109            }
110        }
111        Ok(Self {
112            source,
113            provenance,
114            measurements,
115        })
116    }
117
118    fn exact_applicable(&self) -> bool {
119        self.provenance.is_some_and(|provenance| {
120            is_exact_unity_fbx(self.source, provenance) && has_declared_work(provenance)
121        })
122    }
123}
124
125impl Check for EngineRootMotionCheck<'_> {
126    fn id(&self) -> &'static str {
127        ENGINE_ROOT_MOTION_CHECK_ID
128    }
129
130    fn allows_severity_off(&self) -> bool {
131        false
132    }
133
134    fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
135        if self.exact_applicable() {
136            Applicability::Applicable
137        } else {
138            Applicability::NotApplicable
139        }
140    }
141
142    fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
143        let Some(provenance) = self.provenance.filter(|_| self.exact_applicable()) else {
144            return empty_output();
145        };
146        let demand = plan_demand(provenance);
147        let (candidate_capacity, summary_required) = match demand {
148            PredictionFacetDemandV2::Exact(count) => (count, false),
149            PredictionFacetDemandV2::NPlusOne => (
150                PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE.saturating_sub(1),
151                true,
152            ),
153        };
154        evaluate_allocated(
155            self.source,
156            provenance,
157            self.measurements,
158            candidate_capacity,
159            summary_required,
160        )
161    }
162
163    fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
164        self.provenance
165            .filter(|_| self.exact_applicable())
166            .map_or(PredictionFacetDemandV2::Exact(0), plan_demand)
167    }
168
169    fn evaluate_with_prediction_allocation_v2(
170        &self,
171        _ctx: &CheckCtx<'_>,
172        allocation: PredictionRuleAllocationV2<'_>,
173    ) -> CheckOutput {
174        let Some(provenance) = self.provenance.filter(|_| self.exact_applicable()) else {
175            return empty_output();
176        };
177        evaluate_allocated(
178            self.source,
179            provenance,
180            self.measurements,
181            allocation.candidate_capacity(),
182            allocation.summary_required(),
183        )
184    }
185}
186
187fn has_declared_work(provenance: &PredictionProvenanceV6) -> bool {
188    // Incomplete intent or settings evidence is applicable even when the
189    // retained prefix has no owner. Otherwise an unvisited declaration tail
190    // could be mistaken for a complete-empty project and incorrectly be N/A.
191    let intent = provenance.root_motion_project_intent();
192    if intent.clip_coverage() != EngineRootMotionProjectIntentCoverageV1::Complete
193        || provenance.base().base().settings().clip_coverage().state()
194            != ResolvedEngineSettingsCoverageStateV2::Complete
195    {
196        return true;
197    }
198    match intent.declared_axis_candidates() {
199        EngineRootMotionProjectIntentCountV1::Exact { count } => count != 0,
200        EngineRootMotionProjectIntentCountV1::NPlusOne => true,
201    }
202}
203
204fn plan_demand(provenance: &PredictionProvenanceV6) -> PredictionFacetDemandV2 {
205    if atomic_unavailable_reasons(provenance).is_some() {
206        return PredictionFacetDemandV2::Exact(1);
207    }
208    match provenance
209        .root_motion_project_intent()
210        .declared_axis_candidates()
211    {
212        EngineRootMotionProjectIntentCountV1::Exact { count } => usize::try_from(count)
213            .ok()
214            .filter(|count| *count <= PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE)
215            .map_or(
216                PredictionFacetDemandV2::Exact(1),
217                PredictionFacetDemandV2::Exact,
218            ),
219        EngineRootMotionProjectIntentCountV1::NPlusOne => PredictionFacetDemandV2::Exact(1),
220    }
221}
222
223fn atomic_unavailable_reasons(
224    provenance: &PredictionProvenanceV6,
225) -> Option<Vec<PredictionUnavailableReasonV2>> {
226    let mut reasons = Vec::new();
227    if provenance
228        .base()
229        .base()
230        .raw_source()
231        .clips_coverage()
232        .state()
233        != RawSourceSetCoverageStateV1::Complete
234        || provenance.raw_transform_paths().coverage() != RawTransformPathCoverageV1::Complete
235    {
236        reasons.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
237    }
238    let intent = provenance.root_motion_project_intent();
239    if intent.clip_coverage() != EngineRootMotionProjectIntentCoverageV1::Complete
240        || has_unmapped_declared_work(intent)
241    {
242        reasons.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
243    }
244    if intent.declared_axis_candidates().overflowed()
245        || intent.unmapped_declared_axis_candidates().overflowed()
246    {
247        reasons.push(custom_reason(INTENT_WORK_BUDGET_REASON));
248    }
249    if provenance.base().base().settings().clip_coverage().state()
250        != ResolvedEngineSettingsCoverageStateV2::Complete
251    {
252        reasons.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
253    }
254    (!reasons.is_empty()).then_some(reasons)
255}
256
257fn evaluate_allocated(
258    source: &LoadedSource,
259    provenance: &PredictionProvenanceV6,
260    measurements: &[ClipMeasurements],
261    candidate_capacity: usize,
262    summary_required: bool,
263) -> CheckOutput {
264    let mut findings = Vec::new();
265    let mut evaluated_scopes = Vec::new();
266    let mut facets =
267        Vec::with_capacity(candidate_capacity.saturating_add(usize::from(summary_required)));
268
269    if let Some(reasons) = atomic_unavailable_reasons(provenance) {
270        if candidate_capacity != 0 {
271            facets.push(unavailable(
272                inventory_scope(),
273                inventory_basis(provenance),
274                reasons,
275            ));
276        }
277    } else {
278        let duplicate_names = duplicate_clip_names(provenance);
279        let path = configured_path(provenance);
280        let path_resolution = path
281            .as_ref()
282            .map(|path| provenance.raw_transform_paths().resolve(path));
283        let explicit_root = provenance
284            .root_motion_project_intent()
285            .resolved_root_bone_index();
286        let explicit_root_name = explicit_root
287            .and_then(|index| usize::try_from(index).ok())
288            .and_then(|index| source.document().skeleton.bones.get(index))
289            .map(|bone| bone.name.as_str());
290
291        'clips: for clip in provenance.root_motion_project_intent().clips() {
292            for (axis, owner) in declared_axes(clip) {
293                if facets.len() == candidate_capacity {
294                    break 'clips;
295                }
296                let (Some(normalized_clip_index), Some(clip_name)) = (
297                    clip.normalized_clip_index()
298                        .and_then(|index| usize::try_from(index).ok()),
299                    clip.normalized_clip_name(),
300                ) else {
301                    continue;
302                };
303                let scope = axis_scope(clip.source_clip_index(), axis);
304                let setting_id = axis_setting(axis);
305                let clip_setting = provenance
306                    .base()
307                    .base()
308                    .settings()
309                    .clip_row(normalized_clip_index as u64, clip_name)
310                    .and_then(|settings| settings.setting(setting_id));
311                let measurement = measurements.get(normalized_clip_index);
312                let duplicate_name_count = duplicate_names.get(clip_name).copied().unwrap_or(0);
313                let basis = facet_basis(
314                    source,
315                    provenance,
316                    clip.source_clip_index(),
317                    clip_name,
318                    Some(normalized_clip_index),
319                    clip.normalized_clip_mapping_state(),
320                    duplicate_name_count,
321                    axis,
322                    owner,
323                    path.as_ref(),
324                    path_resolution.as_ref(),
325                    explicit_root,
326                    clip_setting.map(|setting| setting.value_origin()),
327                    measurement,
328                );
329
330                let reason = facet_unavailable_reason(
331                    duplicate_name_count,
332                    path_resolution.as_ref(),
333                    explicit_root,
334                    explicit_root_name,
335                    measurement,
336                    clip_setting.map(|setting| setting.value()),
337                    axis,
338                );
339                if let Some(reason) = reason {
340                    facets.push(unavailable(scope, basis, vec![reason]));
341                    continue;
342                }
343
344                let disposition = importer_disposition(
345                    clip_setting
346                        .expect("available facet has the axis setting")
347                        .value(),
348                )
349                .expect("available facet has bake/extract setting");
350                let compatibility = compatibility(owner, disposition);
351                facets.push(
352                    EnginePredictionFacetV4::available(
353                        scope.clone(),
354                        basis,
355                        EngineMachineResultV1::RootMotionRouting(RootMotionRoutingResultV1 {
356                            axis,
357                            project_owner: owner,
358                            importer_disposition: disposition,
359                            compatibility,
360                        }),
361                    )
362                    .expect("complete Unity routing evidence forms an available facet"),
363                );
364                evaluated_scopes.push(scope.clone());
365                if compatibility == RootMotionCompatibilityV1::Conflict {
366                    findings.push(
367                        Finding::new(
368                            ENGINE_ROOT_MOTION_CHECK_ID,
369                            Severity::Error,
370                            format!(
371                                "clip {:?} assigns {} movement to {}, but Unity imports that axis as {}",
372                                clip_name,
373                                axis_display_name(axis),
374                                owner_name(owner),
375                                disposition_name(disposition),
376                            ),
377                        )
378                        .clip(clip_name)
379                        .prediction_scope(scope),
380                    );
381                }
382            }
383        }
384    }
385
386    if summary_required {
387        facets.push(unavailable(
388            budget_scope(),
389            static_basis(),
390            vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
391        ));
392    }
393    let prediction = EnginePredictionV4::new(provenance.base().base().identity().clone(), facets)
394        .expect("allocated root-motion facets satisfy V4 bounds");
395    CheckOutput::from_coverage(findings, evaluated_scopes, Vec::new()).with_engine_prediction_v6(
396        EnginePredictionV6::new(provenance, prediction)
397            .expect("root-motion V6 prediction binds its immutable provenance"),
398    )
399}
400
401fn configured_path(provenance: &PredictionProvenanceV6) -> Option<RawTransformPathV1> {
402    match provenance
403        .base()
404        .base()
405        .settings()
406        .document_setting(SettingIdV2::RootMotionSource)
407        .map(|setting| setting.value())
408    {
409        Some(EngineSettingValueV2::SourceTransformPath(path)) => {
410            RawTransformPathV1::parse(path).ok()
411        }
412        _ => None,
413    }
414}
415
416fn facet_unavailable_reason(
417    duplicate_name_count: usize,
418    path_resolution: Option<&RawTransformPathResolutionV1>,
419    explicit_root: Option<u64>,
420    explicit_root_name: Option<&str>,
421    measurement: Option<&ClipMeasurements>,
422    setting: Option<&EngineSettingValueV2>,
423    axis: RootMotionAxisV1,
424) -> Option<PredictionUnavailableReasonV2> {
425    if duplicate_name_count > 1 {
426        return Some(PredictionUnavailableReasonV2::MeasurementUnavailable);
427    }
428    match path_resolution {
429        Some(RawTransformPathResolutionV1::NoMatch) | None => {
430            return Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch);
431        }
432        Some(RawTransformPathResolutionV1::Ambiguous { .. }) => {
433            return Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous);
434        }
435        Some(RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
436            return Some(PredictionUnavailableReasonV2::RawSourceIncomplete);
437        }
438        Some(RawTransformPathResolutionV1::Exact(path_match))
439            if explicit_root.is_none() || path_match.projected_bone_index() != explicit_root =>
440        {
441            return Some(custom_reason(ROOT_SOURCE_MISMATCH_REASON));
442        }
443        Some(RawTransformPathResolutionV1::Exact(_)) => {}
444    }
445    let trajectory = measurement
446        .filter(|measurement| {
447            measurement.root_trajectory_availability == MeasurementAvailability::Measured
448        })
449        .and_then(|measurement| measurement.root_trajectory.as_ref());
450    let Some(trajectory) = trajectory else {
451        return Some(PredictionUnavailableReasonV2::MeasurementUnavailable);
452    };
453    if trajectory.source_role != RootTrajectorySourceRole::Root
454        || Some(u64::from(trajectory.bone_index)) != explicit_root
455        || Some(trajectory.bone_name.as_str()) != explicit_root_name
456    {
457        return Some(custom_reason(ROOT_SOURCE_MISMATCH_REASON));
458    }
459    let measured = match axis {
460        RootMotionAxisV1::HorizontalXz | RootMotionAxisV1::VerticalY => {
461            trajectory.translation_availability == MeasurementAvailability::Measured
462                && trajectory.translation.is_some()
463        }
464        RootMotionAxisV1::Yaw => {
465            trajectory.yaw_availability == MeasurementAvailability::Measured
466                && trajectory.yaw.is_some()
467        }
468    };
469    if !measured {
470        return Some(PredictionUnavailableReasonV2::MeasurementUnavailable);
471    }
472    let Some(setting) = setting else {
473        return Some(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
474    };
475    if importer_disposition(setting).is_none() {
476        return Some(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
477    }
478    None
479}
480
481fn declared_axes(
482    clip: &animsmith_core::EngineRootMotionClipIntentV1,
483) -> impl Iterator<Item = (RootMotionAxisV1, RootMotionProjectOwnerV1)> {
484    [
485        (RootMotionAxisV1::HorizontalXz, clip.movement_owner_xz()),
486        (RootMotionAxisV1::VerticalY, clip.movement_owner_y()),
487        (RootMotionAxisV1::Yaw, clip.movement_owner_yaw()),
488    ]
489    .into_iter()
490    .filter_map(|(axis, owner)| owner.map(|owner| (axis, owner)))
491}
492
493fn duplicate_clip_names(provenance: &PredictionProvenanceV6) -> BTreeMap<&str, usize> {
494    let mut counts = BTreeMap::new();
495    for clip in provenance.root_motion_project_intent().clips() {
496        if let Some(name) = clip.normalized_clip_name() {
497            *counts.entry(name).or_insert(0) += 1;
498        }
499    }
500    counts
501}
502
503fn has_unmapped_declared_work(intent: &animsmith_core::EngineRootMotionProjectIntentV1) -> bool {
504    match intent.unmapped_declared_axis_candidates() {
505        EngineRootMotionProjectIntentCountV1::Exact { count } => count != 0,
506        EngineRootMotionProjectIntentCountV1::NPlusOne => true,
507    }
508}
509
510fn importer_disposition(setting: &EngineSettingValueV2) -> Option<RootMotionImporterDispositionV1> {
511    match setting {
512        EngineSettingValueV2::BakeOrExtract(EngineBakeOrExtractV1::Bake) => {
513            Some(RootMotionImporterDispositionV1::BakedIntoPose)
514        }
515        EngineSettingValueV2::BakeOrExtract(EngineBakeOrExtractV1::Extract) => {
516            Some(RootMotionImporterDispositionV1::StoredAsRootMotion)
517        }
518        _ => None,
519    }
520}
521
522fn compatibility(
523    owner: RootMotionProjectOwnerV1,
524    disposition: RootMotionImporterDispositionV1,
525) -> RootMotionCompatibilityV1 {
526    if matches!(
527        (owner, disposition),
528        (
529            RootMotionProjectOwnerV1::Gameplay,
530            RootMotionImporterDispositionV1::BakedIntoPose
531        ) | (
532            RootMotionProjectOwnerV1::Animation,
533            RootMotionImporterDispositionV1::StoredAsRootMotion
534        )
535    ) {
536        RootMotionCompatibilityV1::Compatible
537    } else {
538        RootMotionCompatibilityV1::Conflict
539    }
540}
541
542#[allow(clippy::too_many_arguments)]
543fn facet_basis(
544    source: &LoadedSource,
545    provenance: &PredictionProvenanceV6,
546    source_clip_index: u64,
547    clip_name: &str,
548    normalized_clip_index: Option<usize>,
549    mapping_state: EngineRootMotionClipMappingStateV1,
550    duplicate_name_count: usize,
551    axis: RootMotionAxisV1,
552    owner: RootMotionProjectOwnerV1,
553    configured_path: Option<&RawTransformPathV1>,
554    path_resolution: Option<&RawTransformPathResolutionV1>,
555    explicit_root: Option<u64>,
556    setting_origin: Option<EngineSettingValueOriginV3>,
557    measurement: Option<&ClipMeasurements>,
558) -> EnginePredictionBasisV4 {
559    let mut references = static_references();
560    for setting in [
561        SettingIdV2::AnimationType,
562        SettingIdV2::AvatarSetup,
563        SettingIdV2::ImportAnimation,
564        SettingIdV2::RootMotionSource,
565    ] {
566        let origin = provenance
567            .base()
568            .base()
569            .settings()
570            .document_setting(setting)
571            .map(|row| row.value_origin());
572        references.push(project_reference(
573            match setting {
574                SettingIdV2::AnimationType => {
575                    "resolved_setting.document.animation_type.value_origin"
576                }
577                SettingIdV2::AvatarSetup => "resolved_setting.document.avatar_setup.value_origin",
578                SettingIdV2::ImportAnimation => {
579                    "resolved_setting.document.import_animation.value_origin"
580                }
581                SettingIdV2::RootMotionSource => {
582                    "resolved_setting.document.root_motion_source.value_origin"
583                }
584                _ => unreachable!("closed document root-motion setting inventory"),
585            },
586            origin.map_or(PredictionScalarV1::Null, |origin| {
587                token(origin_name(origin))
588            }),
589        ));
590    }
591    if let Some(normalized_clip_index) = normalized_clip_index {
592        references.push(setting_reference(
593            ResolvedSettingLocationV1::Clip {
594                clip_ordinal: normalized_clip_index as u64,
595                clip_name: clip_name.to_owned(),
596            },
597            axis_setting(axis),
598        ));
599    }
600    references.push(project_reference(
601        "root_motion_project_intent.source_clip_index",
602        PredictionScalarV1::UnsignedInteger {
603            value: source_clip_index,
604        },
605    ));
606    references.push(raw_clip_reference(
607        source,
608        source_clip_index,
609        "normalized_clip_index.state",
610    ));
611    if mapping_state == EngineRootMotionClipMappingStateV1::Observed {
612        references.push(raw_clip_reference(
613            source,
614            source_clip_index,
615            "normalized_clip_index.value",
616        ));
617    }
618    references.push(project_reference(
619        "root_motion_project_intent.clip_mapping_state",
620        token(mapping_state_name(mapping_state)),
621    ));
622    references.push(project_reference(
623        "root_motion_project_intent.normalized_clip_index",
624        normalized_clip_index.map_or(PredictionScalarV1::Null, |value| {
625            PredictionScalarV1::UnsignedInteger {
626                value: value as u64,
627            }
628        }),
629    ));
630    references.push(project_reference(
631        "root_motion_project_intent.normalized_clip_name",
632        PredictionScalarV1::text(clip_name).expect("validated intent name is bounded"),
633    ));
634    references.push(project_reference(
635        "measurement.clip_name_match_count",
636        PredictionScalarV1::UnsignedInteger {
637            value: duplicate_name_count as u64,
638        },
639    ));
640    references.push(project_reference(
641        "measurement.clip_identity_state",
642        token(if duplicate_name_count > 1 {
643            "duplicate"
644        } else {
645            "unique"
646        }),
647    ));
648    references.push(project_reference(
649        "root_motion_project_intent.axis",
650        token(axis_name(axis)),
651    ));
652    references.push(project_reference(
653        "root_motion_project_intent.owner",
654        token(owner_name(owner)),
655    ));
656    references.push(project_reference(
657        "resolved_setting.clip.value_origin",
658        setting_origin.map_or(PredictionScalarV1::Null, |origin| {
659            token(origin_name(origin))
660        }),
661    ));
662    references.push(project_reference(
663        "raw_source.clips.coverage",
664        token(
665            match provenance
666                .base()
667                .base()
668                .raw_source()
669                .clips_coverage()
670                .state()
671            {
672                RawSourceSetCoverageStateV1::Complete => "complete",
673                RawSourceSetCoverageStateV1::Partial => "partial",
674                RawSourceSetCoverageStateV1::Unavailable => "unavailable",
675            },
676        ),
677    ));
678    references.push(project_reference(
679        "raw_transform_path_inventory.coverage",
680        token(path_coverage_name(
681            provenance.raw_transform_paths().coverage(),
682        )),
683    ));
684    references.push(project_reference(
685        "resolved_role.root.bone_index",
686        explicit_root.map_or(PredictionScalarV1::Null, |value| {
687            PredictionScalarV1::UnsignedInteger { value }
688        }),
689    ));
690    references.push(project_reference(
691        "root_motion_source.configured_path",
692        configured_path.map_or(PredictionScalarV1::Null, |path| {
693            PredictionScalarV1::text(path.as_str()).expect("validated path is bounded")
694        }),
695    ));
696    references.push(project_reference(
697        "root_motion_source.resolution_state",
698        token(match path_resolution {
699            Some(RawTransformPathResolutionV1::Exact(_)) => "exact",
700            Some(RawTransformPathResolutionV1::NoMatch) => "no_match",
701            Some(RawTransformPathResolutionV1::Ambiguous { .. }) => "ambiguous",
702            Some(RawTransformPathResolutionV1::CoverageIncomplete { .. }) => "coverage_incomplete",
703            None => "invalid_configured_path",
704        }),
705    ));
706    if let Some(RawTransformPathResolutionV1::Ambiguous { matches }) = path_resolution {
707        references.push(project_reference(
708            "root_motion_source.match_count",
709            PredictionScalarV1::UnsignedInteger {
710                value: matches.len() as u64,
711            },
712        ));
713    }
714    if let Some(RawTransformPathResolutionV1::Exact(path_match)) = path_resolution {
715        references.push(project_reference(
716            "root_motion_source.source_node_index",
717            PredictionScalarV1::UnsignedInteger {
718                value: path_match.source_node_index(),
719            },
720        ));
721        references.push(project_reference(
722            "root_motion_source.projected_bone_index",
723            path_match
724                .projected_bone_index()
725                .map_or(PredictionScalarV1::Null, |value| {
726                    PredictionScalarV1::UnsignedInteger { value }
727                }),
728        ));
729        references.push(project_reference(
730            "root_motion_source.path",
731            PredictionScalarV1::text(path_match.path().as_str())
732                .expect("validated matched path is bounded"),
733        ));
734        references.push(project_reference(
735            "root_motion_source.node_kind",
736            token("source"),
737        ));
738        let parent_chain_bytes = serde_json::to_vec(path_match.parent_chain())
739            .expect("raw transform parent chain serializes");
740        references.push(project_reference(
741            "root_motion_source.parent_chain_count",
742            PredictionScalarV1::UnsignedInteger {
743                value: path_match.parent_chain().len() as u64,
744            },
745        ));
746        references.push(project_reference(
747            "root_motion_source.parent_chain_sha256",
748            PredictionScalarV1::text(animsmith_core::sha256_hex(&parent_chain_bytes))
749                .expect("SHA-256 spelling is bounded"),
750        ));
751    }
752    if duplicate_name_count == 1
753        && let Some(measurement) = measurement
754    {
755        append_measurement_references(&mut references, clip_name, axis, measurement);
756    }
757    EnginePredictionBasisV4::new(references).expect("root-motion facet basis is bounded and valid")
758}
759
760fn append_measurement_references(
761    references: &mut Vec<PredictionBasisReferenceV4>,
762    clip_name: &str,
763    axis: RootMotionAxisV1,
764    measurement: &ClipMeasurements,
765) {
766    let escaped = escape_json_pointer_component(clip_name);
767    let prefix = format!("/measurements/clips/{escaped}/root_trajectory");
768    references.push(measurement_reference(
769        format!("{prefix}_availability"),
770        token(measurement_availability_name(
771            measurement.root_trajectory_availability,
772        )),
773    ));
774    let Some(trajectory) = measurement.root_trajectory.as_ref() else {
775        return;
776    };
777    references.push(measurement_reference(
778        format!("{prefix}/bone_index"),
779        PredictionScalarV1::UnsignedInteger {
780            value: u64::from(trajectory.bone_index),
781        },
782    ));
783    references.push(measurement_reference(
784        format!("{prefix}/source_role"),
785        token(trajectory.source_role.as_str()),
786    ));
787    match axis {
788        RootMotionAxisV1::HorizontalXz | RootMotionAxisV1::VerticalY => {
789            references.push(measurement_reference(
790                format!("{prefix}/translation_availability"),
791                token(measurement_availability_name(
792                    trajectory.translation_availability,
793                )),
794            ));
795            references.push(project_reference(
796                "measurement.root_translation_present",
797                PredictionScalarV1::Boolean {
798                    value: trajectory.translation.is_some(),
799                },
800            ));
801        }
802        RootMotionAxisV1::Yaw => {
803            references.push(measurement_reference(
804                format!("{prefix}/yaw_availability"),
805                token(measurement_availability_name(trajectory.yaw_availability)),
806            ));
807            references.push(project_reference(
808                "measurement.root_yaw_present",
809                PredictionScalarV1::Boolean {
810                    value: trajectory.yaw.is_some(),
811                },
812            ));
813        }
814    }
815}
816
817fn inventory_basis(provenance: &PredictionProvenanceV6) -> EnginePredictionBasisV4 {
818    let mut references = static_references();
819    references.push(project_reference(
820        "raw_source.clips.coverage",
821        token(
822            match provenance
823                .base()
824                .base()
825                .raw_source()
826                .clips_coverage()
827                .state()
828            {
829                RawSourceSetCoverageStateV1::Complete => "complete",
830                RawSourceSetCoverageStateV1::Partial => "partial",
831                RawSourceSetCoverageStateV1::Unavailable => "unavailable",
832            },
833        ),
834    ));
835    references.push(project_reference(
836        "raw_transform_path_inventory.coverage",
837        token(path_coverage_name(
838            provenance.raw_transform_paths().coverage(),
839        )),
840    ));
841    references.push(project_reference(
842        "root_motion_project_intent.clip_coverage",
843        token(
844            match provenance.root_motion_project_intent().clip_coverage() {
845                EngineRootMotionProjectIntentCoverageV1::Complete => "complete",
846                EngineRootMotionProjectIntentCoverageV1::PartialProjectionBudgetExceeded => {
847                    "partial_projection_budget_exceeded"
848                }
849            },
850        ),
851    ));
852    references.push(project_reference(
853        "root_motion_project_intent.declared_axis_candidates",
854        count_scalar(
855            provenance
856                .root_motion_project_intent()
857                .declared_axis_candidates(),
858        ),
859    ));
860    references.push(project_reference(
861        "root_motion_project_intent.unmapped_declared_axis_candidates",
862        count_scalar(
863            provenance
864                .root_motion_project_intent()
865                .unmapped_declared_axis_candidates(),
866        ),
867    ));
868    references.push(project_reference(
869        "resolved_settings.clips.coverage",
870        token(
871            match provenance.base().base().settings().clip_coverage().state() {
872                ResolvedEngineSettingsCoverageStateV2::Complete => "complete",
873                ResolvedEngineSettingsCoverageStateV2::Partial => "partial",
874            },
875        ),
876    ));
877    EnginePredictionBasisV4::new(references).expect("inventory summary basis is valid")
878}
879
880fn static_basis() -> EnginePredictionBasisV4 {
881    EnginePredictionBasisV4::new(static_references()).expect("static root-motion basis is valid")
882}
883
884fn static_references() -> Vec<PredictionBasisReferenceV4> {
885    let mut references = vec![
886        profile_fact_reference(),
887        primary_source_reference(MODEL_IMPORT_SOURCE),
888        primary_source_reference(CLIP_IMPORT_SOURCE),
889        primary_source_reference(MOTION_NODE_SOURCE),
890    ];
891    for setting in [
892        SettingIdV2::AnimationType,
893        SettingIdV2::AvatarSetup,
894        SettingIdV2::ImportAnimation,
895        SettingIdV2::RootMotionSource,
896    ] {
897        references.push(setting_reference(
898            ResolvedSettingLocationV1::Document,
899            setting,
900        ));
901    }
902    references
903}
904
905fn profile_fact_reference() -> PredictionBasisReferenceV4 {
906    lift(
907        PredictionBasisReferenceV1::profile_fact("root_motion_addressability")
908            .expect("static root-motion fact id"),
909    )
910}
911
912fn primary_source_reference(source: &'static str) -> PredictionBasisReferenceV4 {
913    lift(PredictionBasisReferenceV1::primary_source(source).expect("static Unity source id"))
914}
915
916fn setting_reference(
917    location: ResolvedSettingLocationV1,
918    setting: SettingIdV2,
919) -> PredictionBasisReferenceV4 {
920    lift(
921        PredictionBasisReferenceV1::resolved_setting(location, setting.as_str())
922            .expect("static Unity setting id"),
923    )
924}
925
926fn project_reference(field: &'static str, value: PredictionScalarV1) -> PredictionBasisReferenceV4 {
927    lift(PredictionBasisReferenceV1::project_field(field, value).expect("static project field id"))
928}
929
930fn measurement_reference(pointer: String, value: PredictionScalarV1) -> PredictionBasisReferenceV4 {
931    lift(PredictionBasisReferenceV1::measurement_v16(
932        MeasurementPointerV1::new(pointer).expect("measurement pointer is canonical"),
933        value,
934    ))
935}
936
937fn lift(reference: PredictionBasisReferenceV1) -> PredictionBasisReferenceV4 {
938    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference))
939}
940
941fn raw_clip_reference(
942    source: &LoadedSource,
943    source_clip_index: u64,
944    field: &'static str,
945) -> PredictionBasisReferenceV4 {
946    lift(PredictionBasisReferenceV1::raw_source(
947        RawSourceBasisReferenceV1::from_source(
948            RawSourceDomainV1::Clip,
949            RawSourceKeyV1::Clip { source_clip_index },
950            RawSourceFieldIdV1::new(field).expect("static raw clip field"),
951            source.source_facts(),
952        )
953        .expect("same-load intent row has the cited raw clip field"),
954    ))
955}
956
957fn token(value: &'static str) -> PredictionScalarV1 {
958    PredictionScalarV1::token(value).expect("static token is valid")
959}
960
961fn count_scalar(count: EngineRootMotionProjectIntentCountV1) -> PredictionScalarV1 {
962    match count {
963        EngineRootMotionProjectIntentCountV1::Exact { count } => {
964            PredictionScalarV1::UnsignedInteger { value: count }
965        }
966        EngineRootMotionProjectIntentCountV1::NPlusOne => token("n_plus_one"),
967    }
968}
969
970fn unavailable(
971    scope: EvaluationScope,
972    basis: EnginePredictionBasisV4,
973    reasons: Vec<PredictionUnavailableReasonV2>,
974) -> EnginePredictionFacetV4 {
975    EnginePredictionFacetV4::required_unavailable(scope, basis, reasons)
976        .expect("typed root-motion unavailability is valid")
977}
978
979fn custom_reason(code: &'static str) -> PredictionUnavailableReasonV2 {
980    PredictionUnavailableReasonV2::custom(code).expect("static unavailable reason is valid")
981}
982
983fn axis_setting(axis: RootMotionAxisV1) -> SettingIdV2 {
984    match axis {
985        RootMotionAxisV1::HorizontalXz => SettingIdV2::RootPositionXz,
986        RootMotionAxisV1::VerticalY => SettingIdV2::RootPositionY,
987        RootMotionAxisV1::Yaw => SettingIdV2::RootRotation,
988    }
989}
990
991fn axis_scope(source_clip_index: u64, axis: RootMotionAxisV1) -> EvaluationScope {
992    EvaluationScope::new(EvaluationScopeCode::custom(CLIP_AXIS_SCOPE)).subject(format!(
993        "source_clip:{source_clip_index:020}:axis:{}",
994        axis_name(axis)
995    ))
996}
997
998fn inventory_scope() -> EvaluationScope {
999    EvaluationScope::new(EvaluationScopeCode::custom(INVENTORY_SCOPE))
1000}
1001
1002fn budget_scope() -> EvaluationScope {
1003    EvaluationScope::new(EvaluationScopeCode::custom(BUDGET_SCOPE))
1004}
1005
1006fn axis_name(axis: RootMotionAxisV1) -> &'static str {
1007    match axis {
1008        RootMotionAxisV1::HorizontalXz => "horizontal_xz",
1009        RootMotionAxisV1::VerticalY => "vertical_y",
1010        RootMotionAxisV1::Yaw => "yaw",
1011    }
1012}
1013
1014fn axis_display_name(axis: RootMotionAxisV1) -> &'static str {
1015    match axis {
1016        RootMotionAxisV1::HorizontalXz => "horizontal XZ",
1017        RootMotionAxisV1::VerticalY => "vertical Y",
1018        RootMotionAxisV1::Yaw => "yaw",
1019    }
1020}
1021
1022fn owner_name(owner: RootMotionProjectOwnerV1) -> &'static str {
1023    match owner {
1024        RootMotionProjectOwnerV1::Gameplay => "gameplay",
1025        RootMotionProjectOwnerV1::Animation => "animation",
1026    }
1027}
1028
1029fn disposition_name(disposition: RootMotionImporterDispositionV1) -> &'static str {
1030    match disposition {
1031        RootMotionImporterDispositionV1::BakedIntoPose => "baked into the pose",
1032        RootMotionImporterDispositionV1::StoredAsRootMotion => "stored as root motion",
1033    }
1034}
1035
1036fn origin_name(origin: EngineSettingValueOriginV3) -> &'static str {
1037    match origin {
1038        EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
1039        EngineSettingValueOriginV3::ProfileDefault => "profile_default",
1040    }
1041}
1042
1043fn mapping_state_name(state: EngineRootMotionClipMappingStateV1) -> &'static str {
1044    match state {
1045        EngineRootMotionClipMappingStateV1::Observed => "observed",
1046        EngineRootMotionClipMappingStateV1::ProvenAbsent => "proven_absent",
1047        EngineRootMotionClipMappingStateV1::Unavailable => "unavailable",
1048    }
1049}
1050
1051fn measurement_availability_name(availability: MeasurementAvailability) -> &'static str {
1052    match availability {
1053        MeasurementAvailability::Measured => "measured",
1054        MeasurementAvailability::NotApplicable => "not_applicable",
1055        MeasurementAvailability::Unavailable => "unavailable",
1056        _ => "unknown",
1057    }
1058}
1059
1060fn path_coverage_name(coverage: RawTransformPathCoverageV1) -> &'static str {
1061    match coverage {
1062        RawTransformPathCoverageV1::Complete => "complete",
1063        RawTransformPathCoverageV1::Partial(_) => "partial",
1064        RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
1065    }
1066}
1067
1068fn escape_json_pointer_component(value: &str) -> String {
1069    value.replace('~', "~0").replace('/', "~1")
1070}
1071
1072fn intent_matches_source(source: &LoadedSource, provenance: &PredictionProvenanceV6) -> bool {
1073    let source_rows = source.source_facts().clips().rows();
1074    provenance
1075        .root_motion_project_intent()
1076        .clips()
1077        .iter()
1078        .all(|intent| {
1079            let Ok(source_index) = usize::try_from(intent.source_clip_index()) else {
1080                return false;
1081            };
1082            let Some(source_row) = source_rows.get(source_index) else {
1083                return false;
1084            };
1085            match source_row.normalized_clip_index().state() {
1086                SourceObservationStateV1::Observed(normalized_index) => {
1087                    intent.normalized_clip_mapping_state()
1088                        == EngineRootMotionClipMappingStateV1::Observed
1089                        && intent.normalized_clip_index() == u64::try_from(*normalized_index).ok()
1090                        && source
1091                            .document()
1092                            .clips
1093                            .get(*normalized_index)
1094                            .is_some_and(|clip| {
1095                                intent
1096                                    .normalized_clip_name()
1097                                    .is_some_and(|name| clip.name == name)
1098                            })
1099                }
1100                SourceObservationStateV1::ProvenAbsent
1101                    if intent.normalized_clip_mapping_state()
1102                        == EngineRootMotionClipMappingStateV1::ProvenAbsent =>
1103                {
1104                    intent.normalized_clip_index().is_none()
1105                        && intent.normalized_clip_name().is_none()
1106                }
1107                SourceObservationStateV1::Unavailable(_)
1108                    if intent.normalized_clip_mapping_state()
1109                        == EngineRootMotionClipMappingStateV1::Unavailable =>
1110                {
1111                    intent.normalized_clip_index().is_none()
1112                        && intent.normalized_clip_name().is_none()
1113                }
1114                SourceObservationStateV1::ProvenAbsent
1115                | SourceObservationStateV1::Unavailable(_) => false,
1116            }
1117        })
1118}
1119
1120fn raw_transform_paths_match(source: &LoadedSource, provenance: &PredictionProvenanceV6) -> bool {
1121    match source.raw_transform_path_inventory() {
1122        Some(inventory) => inventory == provenance.raw_transform_paths(),
1123        None => matches!(
1124            provenance.raw_transform_paths().coverage(),
1125            RawTransformPathCoverageV1::Unavailable(
1126                RawTransformPathCoverageReasonV1::LoaderEvidenceUnavailable
1127            )
1128        ),
1129    }
1130}
1131
1132fn is_unity_tuple(provenance: &PredictionProvenanceV6) -> bool {
1133    let selection = provenance.base().base().profile().selection();
1134    selection.family() == UNITY_FAMILY
1135        && selection.profile_revision() == UNITY_PROFILE_REVISION
1136        && selection.engine_version() == UNITY_ENGINE_VERSION
1137        && selection.importer() == UNITY_IMPORTER
1138}
1139
1140fn has_frozen_unity_profile(provenance: &PredictionProvenanceV6) -> bool {
1141    profiles_v2().iter().any(|profile| {
1142        let selection = profile.selection();
1143        selection.family() == UNITY_FAMILY
1144            && selection.profile_revision() == UNITY_PROFILE_REVISION
1145            && selection.engine_version() == UNITY_ENGINE_VERSION
1146            && selection.importer() == UNITY_IMPORTER
1147            && crate::project_engine_profile_v2(profile)
1148                .is_ok_and(|projected| &projected == provenance.base().base().profile())
1149    })
1150}
1151
1152fn is_exact_unity_fbx(source: &LoadedSource, provenance: &PredictionProvenanceV6) -> bool {
1153    source.source_facts().format() == SourceFormatV1::Fbx
1154        && provenance.base().base().source_format() == SourceFormatV1::Fbx
1155        && is_unity_tuple(provenance)
1156        && has_frozen_unity_profile(provenance)
1157}
1158
1159fn empty_output() -> CheckOutput {
1160    CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
1161}