1use 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
32pub 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
51pub struct EngineRootMotionCheck<'a> {
57 source: &'a LoadedSource,
58 provenance: Option<&'a PredictionProvenanceV6>,
59 measurements: &'a [ClipMeasurements],
60}
61
62impl<'a> EngineRootMotionCheck<'a> {
63 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 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}