1use crate::project_prediction_provenance_v4;
8use crate::{
9 BakeOrExtract, EngineProfile, ResolvedClipSettings, ResolvedProfile, SettingId, SettingValue,
10 project_prediction_provenance_v1,
11};
12use animsmith_core::measure::{
13 ClipMeasurements, FrameGridMeasurement, LoopEndpointMode, MeasurementAvailability,
14};
15use animsmith_core::{
16 Config, InputIdentity, LoadedSource, MetricGrids, MovementOwner, PredictionProvenanceV1,
17 RawSourceSetCoverageStateV1, SourceObservationStateV1, SourceSetCoverageStateV1, SourceTextV1,
18 SourceUnavailableReasonV1, ToolInfo, resolve_configured_roles,
19};
20use serde::de::{Error as _, SeqAccess, Visitor};
21use serde::{Deserialize, Deserializer, Serialize};
22use serde_json::value::RawValue;
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt;
25use std::io::Read;
26use std::marker::PhantomData;
27
28pub const ENGINE_IMPORT_ADVICE_V1_ID: &str = "urn:animsmith:schema:engine-import-advice:1";
30pub const ENGINE_IMPORT_ADVICE_SCHEMA_VERSION: u32 = 1;
32pub const ENGINE_IMPORT_ADVICE_COMMAND: &str = "generate-import-advice";
34pub const ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
36
37const MAX_CLIPS: usize = animsmith_core::RAW_SOURCE_V1_MAX_CLIPS;
38const MAX_TEXT_BYTES: usize = animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES;
39const IDENTITY_DOMAIN: &str = "urn:animsmith:engine-import-advice-preimage:1";
40const UNITY_GENERIC_FACTS: (&str, u64) = (
41 "97afc05a02f7f9a946c66945cb84669a8a67d4dae7bf642486b94f1de3a17dd4",
42 3097,
43);
44const UNITY_HUMANOID_FACTS: (&str, u64) = (
45 "43f53df9f26ca3a1248972566029609bcd6b63194cbca399789444622680a12a",
46 2847,
47);
48const UNREAL_FACTS: (&str, u64) = (
49 "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759",
50 2169,
51);
52const GODOT_FACTS: (&str, u64) = (
53 "e9c8316d1655c487b60dd35bbfc70289952c5fa12f4718f0be09c7e9a00fbe87",
54 1166,
55);
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct EngineImportAdviceIdentityV1(InputIdentity);
61
62impl EngineImportAdviceIdentityV1 {
63 pub const fn input_identity(&self) -> &InputIdentity {
65 &self.0
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum EngineImportAdviceStateV1 {
73 Available,
75 Refused,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum EngineImportAdviceRefusalReasonV1 {
83 ProfileSettingsUnmodeled,
85 RawClipInventoryIncomplete,
87 ClipIdentityUnavailable,
89 ClipIdentityMismatch,
91 MeasurementUnavailable,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum EngineImportAdviceSourceUnavailableReasonV1 {
99 Malformed,
101 Discarded,
103 NormalizedAway,
105 BakedAway,
107 LoaderUnsupported,
109 ProjectionBudgetExceeded,
111 ParserUnavailable,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
118pub enum EngineImportAdviceSourceNameV1 {
119 Observed {
121 value: String,
123 },
124 ProvenAbsent,
126 Unavailable {
128 reason: EngineImportAdviceSourceUnavailableReasonV1,
130 },
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum EngineImportAdviceMovementOwnerV1 {
137 Gameplay,
139 Animation,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct EngineImportAdviceClipEvidenceV1 {
147 duration_s: f64,
148 #[serde(
149 default,
150 skip_serializing_if = "Option::is_none",
151 deserialize_with = "deserialize_present_non_null"
152 )]
153 speed_mps: Option<f64>,
154 speed_mps_availability: MeasurementAvailability,
155 #[serde(
156 default,
157 skip_serializing_if = "Option::is_none",
158 deserialize_with = "deserialize_present_non_null"
159 )]
160 loop_endpoint_mode: Option<LoopEndpointMode>,
161 loop_endpoint_mode_availability: MeasurementAvailability,
162 #[serde(
163 default,
164 skip_serializing_if = "Option::is_none",
165 deserialize_with = "deserialize_present_non_null"
166 )]
167 frame_grid: Option<FrameGridMeasurement>,
168 frame_grid_availability: MeasurementAvailability,
169 #[serde(
170 rename = "loop",
171 default,
172 skip_serializing_if = "Option::is_none",
173 deserialize_with = "deserialize_present_non_null"
174 )]
175 looping: Option<bool>,
176 #[serde(
177 default,
178 skip_serializing_if = "Option::is_none",
179 deserialize_with = "deserialize_present_non_null"
180 )]
181 movement_owner_xz: Option<EngineImportAdviceMovementOwnerV1>,
182 #[serde(
183 default,
184 skip_serializing_if = "Option::is_none",
185 deserialize_with = "deserialize_present_non_null"
186 )]
187 movement_owner_y: Option<EngineImportAdviceMovementOwnerV1>,
188 #[serde(
189 default,
190 skip_serializing_if = "Option::is_none",
191 deserialize_with = "deserialize_present_non_null"
192 )]
193 movement_owner_yaw: Option<EngineImportAdviceMovementOwnerV1>,
194}
195
196impl EngineImportAdviceClipEvidenceV1 {
197 pub const fn duration_s(&self) -> f64 {
199 self.duration_s
200 }
201 pub const fn speed_mps(&self) -> Option<f64> {
203 self.speed_mps
204 }
205 pub const fn speed_mps_availability(&self) -> MeasurementAvailability {
207 self.speed_mps_availability
208 }
209 pub const fn loop_endpoint_mode(&self) -> Option<LoopEndpointMode> {
211 self.loop_endpoint_mode
212 }
213 pub const fn loop_endpoint_mode_availability(&self) -> MeasurementAvailability {
215 self.loop_endpoint_mode_availability
216 }
217 pub const fn frame_grid(&self) -> Option<FrameGridMeasurement> {
219 self.frame_grid
220 }
221 pub const fn frame_grid_availability(&self) -> MeasurementAvailability {
223 self.frame_grid_availability
224 }
225 pub const fn looping(&self) -> Option<bool> {
227 self.looping
228 }
229 pub const fn movement_owner_xz(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
231 self.movement_owner_xz
232 }
233 pub const fn movement_owner_y(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
235 self.movement_owner_y
236 }
237 pub const fn movement_owner_yaw(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
239 self.movement_owner_yaw
240 }
241
242 fn validate(&self) -> Result<(), EngineImportAdviceError> {
243 if !self.duration_s.is_finite()
244 || self.duration_s < 0.0
245 || self
246 .speed_mps
247 .is_some_and(|value| !value.is_finite() || value < 0.0)
248 {
249 return Err(EngineImportAdviceError::InvalidMeasurement);
250 }
251 validate_optional(self.speed_mps.is_some(), self.speed_mps_availability)?;
252 validate_optional(
253 self.loop_endpoint_mode.is_some(),
254 self.loop_endpoint_mode_availability,
255 )?;
256 let loop_endpoint_applicability_matches = match self.looping {
257 Some(true) => {
258 self.loop_endpoint_mode_availability != MeasurementAvailability::NotApplicable
259 }
260 Some(false) | None => {
261 self.loop_endpoint_mode_availability == MeasurementAvailability::NotApplicable
262 }
263 };
264 if !loop_endpoint_applicability_matches {
265 return Err(EngineImportAdviceError::InvalidMeasurement);
266 }
267 validate_optional(self.frame_grid.is_some(), self.frame_grid_availability)?;
268 if self.frame_grid.is_some_and(|grid| {
269 !grid.fps.is_finite() || grid.fps <= 0.0 || grid.frame_intervals == 0
270 }) {
271 return Err(EngineImportAdviceError::InvalidMeasurement);
272 }
273 Ok(())
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct EngineImportAdviceClipV1 {
281 source_clip_index: u64,
282 normalized_clip_index: u64,
283 normalized_clip_name: String,
284 source_name: EngineImportAdviceSourceNameV1,
285 evidence: EngineImportAdviceClipEvidenceV1,
286}
287
288impl EngineImportAdviceClipV1 {
289 pub const fn source_clip_index(&self) -> u64 {
291 self.source_clip_index
292 }
293 pub const fn normalized_clip_index(&self) -> u64 {
295 self.normalized_clip_index
296 }
297 pub fn normalized_clip_name(&self) -> &str {
299 &self.normalized_clip_name
300 }
301 pub const fn source_name(&self) -> &EngineImportAdviceSourceNameV1 {
303 &self.source_name
304 }
305 pub const fn evidence(&self) -> &EngineImportAdviceClipEvidenceV1 {
307 &self.evidence
308 }
309
310 fn validate(&self, ordinal: usize) -> Result<(), EngineImportAdviceError> {
311 if self.source_clip_index != ordinal as u64
312 || self.normalized_clip_index >= MAX_CLIPS as u64
313 || self.normalized_clip_name.is_empty()
314 || self.normalized_clip_name.len() > MAX_TEXT_BYTES
315 {
316 return Err(EngineImportAdviceError::InvalidClipIdentity);
317 }
318 if let EngineImportAdviceSourceNameV1::Observed { value } = &self.source_name
319 && (value.is_empty() || value.len() > MAX_TEXT_BYTES)
320 {
321 return Err(EngineImportAdviceError::InvalidClipIdentity);
322 }
323 self.evidence.validate()
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct UnityDocumentAdviceV1 {
331 convert_units: bool,
332 bake_axis_conversion: bool,
333 #[serde(
334 default,
335 skip_serializing_if = "Option::is_none",
336 deserialize_with = "deserialize_present_non_null"
337 )]
338 root_motion_source: Option<String>,
339}
340
341impl UnityDocumentAdviceV1 {
342 pub const fn convert_units(&self) -> bool {
344 self.convert_units
345 }
346 pub const fn bake_axis_conversion(&self) -> bool {
348 self.bake_axis_conversion
349 }
350 pub fn root_motion_source(&self) -> Option<&str> {
352 self.root_motion_source.as_deref()
353 }
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(deny_unknown_fields)]
359pub struct UnityClipAdviceV1 {
360 normalized_clip_index: u64,
361 lock_root_rotation: bool,
362 lock_root_height_y: bool,
363 lock_root_position_xz: bool,
364}
365
366impl UnityClipAdviceV1 {
367 pub const fn normalized_clip_index(&self) -> u64 {
369 self.normalized_clip_index
370 }
371 pub const fn lock_root_rotation(&self) -> bool {
373 self.lock_root_rotation
374 }
375 pub const fn lock_root_height_y(&self) -> bool {
377 self.lock_root_height_y
378 }
379 pub const fn lock_root_position_xz(&self) -> bool {
381 self.lock_root_position_xz
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(tag = "engine", rename_all = "kebab-case", deny_unknown_fields)]
388pub enum EngineImportAdvicePayloadV1 {
389 UnityGeneric {
391 document: UnityDocumentAdviceV1,
393 #[serde(deserialize_with = "deserialize_unity_clips")]
395 clips: Vec<UnityClipAdviceV1>,
396 },
397 UnityHumanoid {
399 document: UnityDocumentAdviceV1,
401 #[serde(deserialize_with = "deserialize_unity_clips")]
403 clips: Vec<UnityClipAdviceV1>,
404 },
405 Unreal,
407 Godot,
409}
410
411impl EngineImportAdvicePayloadV1 {
412 pub const fn unity_document(&self) -> Option<&UnityDocumentAdviceV1> {
414 match self {
415 Self::UnityGeneric { document, .. } | Self::UnityHumanoid { document, .. } => {
416 Some(document)
417 }
418 Self::Unreal | Self::Godot => None,
419 }
420 }
421 pub fn unity_clips(&self) -> &[UnityClipAdviceV1] {
423 match self {
424 Self::UnityGeneric { clips, .. } | Self::UnityHumanoid { clips, .. } => clips,
425 Self::Unreal | Self::Godot => &[],
426 }
427 }
428}
429
430#[derive(Debug, Clone, Serialize)]
432pub struct EngineImportAdviceV1 {
433 schema_version: u32,
434 schema: &'static str,
435 tool: ToolInfo,
436 command: &'static str,
437 identity: EngineImportAdviceIdentityV1,
438 prediction_provenance: PredictionProvenanceV1,
439 state: EngineImportAdviceStateV1,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
442 clips: Vec<EngineImportAdviceClipV1>,
443 payload: EngineImportAdvicePayloadV1,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
448#[non_exhaustive]
449pub enum EngineImportAdviceError {
450 #[error("generate import-advice requires an exact Unity, Unreal, or Godot V1 profile")]
452 UnsupportedProfile,
453 #[error("invalid engine-import-advice configuration: {0}")]
455 InvalidConfig(String),
456 #[error("invalid engine-import-advice V1 header")]
458 WrongHeader,
459 #[error("invalid engine-import-advice producer identity")]
461 InvalidTool,
462 #[error("invalid prediction provenance: {0}")]
464 InvalidProvenance(String),
465 #[error("engine-import-advice profile does not match its payload")]
467 ProfileMismatch,
468 #[error("invalid engine-import-advice lifecycle")]
470 InvalidLifecycle,
471 #[error("invalid engine-import-advice clip identity")]
473 InvalidClipIdentity,
474 #[error("invalid engine-import-advice measurement")]
476 InvalidMeasurement,
477 #[error("engine-import-advice Unity settings do not match prediction provenance")]
479 UnitySettingsMismatch,
480 #[error("engine-import-advice has {found} clips; limit is {limit}")]
482 TooManyClips {
483 found: usize,
485 limit: usize,
487 },
488 #[error("engine-import-advice identity mismatch")]
490 IdentityMismatch,
491 #[error("generate import-advice requires an exact Godot or Unreal V2 profile")]
493 UnsupportedProfileV2,
494 #[error("invalid engine-import-advice V2 header")]
496 WrongV2Header,
497 #[error("invalid V2 prediction provenance: {0}")]
499 InvalidV2Provenance(String),
500 #[error("invalid V2 prediction: {0}")]
502 InvalidV2Prediction(String),
503 #[error("invalid engine-import-advice V2 lifecycle")]
505 InvalidV2Lifecycle,
506 #[error("invalid engine-import-advice V2 projection")]
508 InvalidV2Projection,
509 #[error("invalid engine-import-advice V2 settings")]
511 InvalidV2Settings,
512}
513
514#[derive(Debug, thiserror::Error)]
516#[non_exhaustive]
517pub enum EngineImportAdviceReadError {
518 #[error("cannot read engine-import-advice document: {source}")]
520 Io {
521 #[source]
523 source: std::io::Error,
524 },
525 #[error("engine-import-advice document exceeds {limit} bytes")]
527 ReportTooLarge {
528 limit: u64,
530 },
531 #[error("invalid engine-import-advice JSON: {source}")]
533 InvalidJson {
534 #[source]
536 source: serde_json::Error,
537 },
538}
539
540impl EngineImportAdviceV1 {
541 pub fn supports_profile(profile: &EngineProfile) -> bool {
547 let selection = profile.selection();
548 let facts = profile.facts_identity();
549 [
550 (
551 "unity-generic",
552 1,
553 "6000.3",
554 "fbx-model-importer",
555 UNITY_GENERIC_FACTS,
556 ),
557 (
558 "unity-humanoid",
559 1,
560 "6000.3",
561 "fbx-model-importer",
562 UNITY_HUMANOID_FACTS,
563 ),
564 ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
565 ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS),
566 ]
567 .into_iter()
568 .any(|(family, revision, version, importer, identity)| {
569 selection.family() == family
570 && selection.profile_revision() == revision
571 && selection.engine_version() == version
572 && selection.importer() == importer
573 && facts.sha256() == identity.0
574 && facts.bytes() == identity.1
575 })
576 }
577
578 pub fn from_source(
592 tool: ToolInfo,
593 source: &LoadedSource,
594 profile: &ResolvedProfile,
595 config: &Config,
596 ) -> Result<Self, EngineImportAdviceError> {
597 config
598 .validate()
599 .map_err(|error| EngineImportAdviceError::InvalidConfig(error.to_string()))?;
600 if !Self::supports_profile(profile.profile()) {
601 return Err(EngineImportAdviceError::UnsupportedProfile);
602 }
603 let prediction_provenance = project_prediction_provenance_v1(profile, source)
604 .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
605 let family = profile.profile().selection().family();
606 let (payload, state, refusal_reason, clips) = match family {
607 "unreal" => (
608 EngineImportAdvicePayloadV1::Unreal,
609 EngineImportAdviceStateV1::Refused,
610 Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
611 Vec::new(),
612 ),
613 "godot" => (
614 EngineImportAdvicePayloadV1::Godot,
615 EngineImportAdviceStateV1::Refused,
616 Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
617 Vec::new(),
618 ),
619 "unity-generic" | "unity-humanoid" => {
620 let generic = family == "unity-generic";
621 let document = unity_document(profile, generic)?;
622 let roles = resolve_configured_roles(&source.document().skeleton, &config.rig);
623 let grids = MetricGrids::new(source.document());
624 let measurements =
625 animsmith_core::measure::measure_document(&grids, &roles, config);
626 match build_clip_evidence(source, &measurements, config) {
627 Ok(clips) => match unity_clip_advice(profile, source.document(), &clips) {
628 Ok(unity_clips) => {
629 let payload = if generic {
630 EngineImportAdvicePayloadV1::UnityGeneric {
631 document,
632 clips: unity_clips,
633 }
634 } else {
635 EngineImportAdvicePayloadV1::UnityHumanoid {
636 document,
637 clips: unity_clips,
638 }
639 };
640 (payload, EngineImportAdviceStateV1::Available, None, clips)
641 }
642 Err(EngineImportAdviceError::UnitySettingsMismatch) => {
643 let payload = if generic {
644 EngineImportAdvicePayloadV1::UnityGeneric {
645 document,
646 clips: Vec::new(),
647 }
648 } else {
649 EngineImportAdvicePayloadV1::UnityHumanoid {
650 document,
651 clips: Vec::new(),
652 }
653 };
654 (
655 payload,
656 EngineImportAdviceStateV1::Refused,
657 Some(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch),
658 Vec::new(),
659 )
660 }
661 Err(error) => return Err(error),
662 },
663 Err(reason) => {
664 let payload = if generic {
665 EngineImportAdvicePayloadV1::UnityGeneric {
666 document,
667 clips: Vec::new(),
668 }
669 } else {
670 EngineImportAdvicePayloadV1::UnityHumanoid {
671 document,
672 clips: Vec::new(),
673 }
674 };
675 (
676 payload,
677 EngineImportAdviceStateV1::Refused,
678 Some(reason),
679 Vec::new(),
680 )
681 }
682 }
683 }
684 _ => return Err(EngineImportAdviceError::UnsupportedProfile),
685 };
686 let mut report = Self {
687 schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
688 schema: ENGINE_IMPORT_ADVICE_V1_ID,
689 tool,
690 command: ENGINE_IMPORT_ADVICE_COMMAND,
691 identity: EngineImportAdviceIdentityV1(InputIdentity::from_bytes(&[])),
692 prediction_provenance,
693 state,
694 refusal_reason,
695 clips,
696 payload,
697 };
698 report.validate_without_identity()?;
699 report.identity = EngineImportAdviceIdentityV1(report.computed_identity());
700 Ok(report)
701 }
702
703 pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
705 &self.identity
706 }
707 pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
709 &self.prediction_provenance
710 }
711 pub const fn state(&self) -> EngineImportAdviceStateV1 {
713 self.state
714 }
715 pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
717 self.refusal_reason
718 }
719 pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
721 &self.clips
722 }
723 pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
725 &self.payload
726 }
727
728 fn validate_without_identity(&self) -> Result<(), EngineImportAdviceError> {
729 if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
730 || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
731 || self.command != ENGINE_IMPORT_ADVICE_COMMAND
732 {
733 return Err(EngineImportAdviceError::WrongHeader);
734 }
735 self.prediction_provenance
736 .validate()
737 .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
738 validate_profile(&self.prediction_provenance, &self.payload)?;
739 if self.clips.len() > MAX_CLIPS {
740 return Err(EngineImportAdviceError::TooManyClips {
741 found: self.clips.len(),
742 limit: MAX_CLIPS,
743 });
744 }
745 let mut normalized = BTreeSet::new();
746 for (ordinal, clip) in self.clips.iter().enumerate() {
747 clip.validate(ordinal)?;
748 if !normalized.insert(clip.normalized_clip_index) {
749 return Err(EngineImportAdviceError::InvalidClipIdentity);
750 }
751 }
752 if normalized
753 .iter()
754 .copied()
755 .ne((0..self.clips.len()).map(|index| index as u64))
756 {
757 return Err(EngineImportAdviceError::InvalidClipIdentity);
758 }
759 let raw_clips_complete = self
760 .prediction_provenance
761 .raw_source()
762 .clips_coverage()
763 .state()
764 == RawSourceSetCoverageStateV1::Complete;
765 match (self.state, self.refusal_reason) {
766 (EngineImportAdviceStateV1::Available, None) => {
767 if !raw_clips_complete
768 || matches!(
769 self.payload,
770 EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
771 )
772 || self.payload.unity_clips().len() != self.clips.len()
773 {
774 return Err(EngineImportAdviceError::InvalidLifecycle);
775 }
776 for (normalized_index, advice) in self.payload.unity_clips().iter().enumerate() {
777 if advice.normalized_clip_index != normalized_index as u64 {
778 return Err(EngineImportAdviceError::InvalidClipIdentity);
779 }
780 }
781 validate_unity_settings_against_provenance(
782 &self.prediction_provenance,
783 &self.clips,
784 &self.payload,
785 )?;
786 }
787 (EngineImportAdviceStateV1::Refused, Some(reason)) => {
788 if !self.clips.is_empty() || !self.payload.unity_clips().is_empty() {
789 return Err(EngineImportAdviceError::InvalidLifecycle);
790 }
791 let unmodeled = matches!(
792 self.payload,
793 EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
794 );
795 if (reason == EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled)
796 != unmodeled
797 {
798 return Err(EngineImportAdviceError::InvalidLifecycle);
799 }
800 if !unmodeled {
801 let coverage_matches_reason = match reason {
802 EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
803 !raw_clips_complete
804 }
805 EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable
806 | EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch
807 | EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => {
808 raw_clips_complete
809 }
810 EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => false,
811 };
812 if !coverage_matches_reason {
813 return Err(EngineImportAdviceError::InvalidLifecycle);
814 }
815 validate_unity_document_against_provenance(
816 &self.prediction_provenance,
817 &self.payload,
818 )?;
819 }
820 }
821 _ => return Err(EngineImportAdviceError::InvalidLifecycle),
822 }
823 Ok(())
824 }
825
826 fn computed_identity(&self) -> InputIdentity {
827 let mut encoder = AdviceEncoder::new(IDENTITY_DOMAIN);
828 encode_identity(
829 &mut encoder,
830 self.prediction_provenance.identity().input_identity(),
831 );
832 encoder.token(match self.state {
833 EngineImportAdviceStateV1::Available => "available",
834 EngineImportAdviceStateV1::Refused => "refused",
835 });
836 encoder.bool(self.refusal_reason.is_some());
837 if let Some(reason) = self.refusal_reason {
838 encoder.token(refusal_reason_name(reason));
839 }
840 encoder.count(self.clips.len());
841 for clip in &self.clips {
842 encode_clip(&mut encoder, clip);
843 }
844 encode_payload(&mut encoder, &self.payload);
845 InputIdentity::from_bytes(&encoder.bytes)
846 }
847}
848
849fn build_clip_evidence(
850 source: &LoadedSource,
851 measurements: &BTreeMap<String, ClipMeasurements>,
852 config: &Config,
853) -> Result<Vec<EngineImportAdviceClipV1>, EngineImportAdviceRefusalReasonV1> {
854 let raw = source.source_facts().clips();
855 if raw.coverage().state() != SourceSetCoverageStateV1::Complete {
856 return Err(EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete);
857 }
858 let document = source.document();
859 if raw.rows().len() != document.clips.len() || measurements.len() != document.clips.len() {
860 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
861 }
862 let mut normalized = BTreeSet::new();
863 let mut rows = Vec::with_capacity(raw.rows().len());
864 for (ordinal, raw_clip) in raw.rows().iter().enumerate() {
865 if raw_clip.source_clip_index() != ordinal {
866 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
867 }
868 let normalized_index = match raw_clip.normalized_clip_index().state() {
869 SourceObservationStateV1::Observed(value) => *value,
870 SourceObservationStateV1::ProvenAbsent | SourceObservationStateV1::Unavailable(_) => {
871 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable);
872 }
873 };
874 if normalized_index >= document.clips.len() || !normalized.insert(normalized_index) {
875 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
876 }
877 let clip = &document.clips[normalized_index];
878 let Some(measurement) = measurements.get(&clip.name) else {
879 return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
880 };
881 let expectations = config.expectations_for(&clip.name);
882 let evidence = EngineImportAdviceClipEvidenceV1 {
883 duration_s: measurement.duration_s,
884 speed_mps: measurement.speed_mps,
885 speed_mps_availability: measurement.speed_mps_availability,
886 loop_endpoint_mode: measurement.loop_endpoint_mode,
887 loop_endpoint_mode_availability: measurement.loop_endpoint_mode_availability,
888 frame_grid: measurement.frame_grid,
889 frame_grid_availability: measurement.frame_grid_availability,
890 looping: expectations.looping,
891 movement_owner_xz: expectations.movement_owner_xz.map(movement_owner),
892 movement_owner_y: expectations.movement_owner_y.map(movement_owner),
893 movement_owner_yaw: expectations.movement_owner_yaw.map(movement_owner),
894 };
895 if evidence.validate().is_err() {
896 return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
897 }
898 rows.push(EngineImportAdviceClipV1 {
899 source_clip_index: ordinal as u64,
900 normalized_clip_index: normalized_index as u64,
901 normalized_clip_name: clip.name.clone(),
902 source_name: source_name(raw_clip.source_name().state()),
903 evidence,
904 });
905 }
906 if normalized.len() != document.clips.len() {
907 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
908 }
909 Ok(rows)
910}
911
912fn unity_document(
913 profile: &ResolvedProfile,
914 generic: bool,
915) -> Result<UnityDocumentAdviceV1, EngineImportAdviceError> {
916 let convert_units = bool_setting(profile.document_settings().get(&SettingId::ConvertUnits))?;
917 let bake_axis_conversion = bool_setting(
918 profile
919 .document_settings()
920 .get(&SettingId::BakeAxisConversion),
921 )?;
922 let root_motion_source = if generic {
923 match profile
924 .document_settings()
925 .get(&SettingId::RootMotionSource)
926 {
927 Some(SettingValue::SourceTransformPath(value)) => Some(value.clone()),
928 _ => return Err(EngineImportAdviceError::UnitySettingsMismatch),
929 }
930 } else {
931 None
932 };
933 Ok(UnityDocumentAdviceV1 {
934 convert_units,
935 bake_axis_conversion,
936 root_motion_source,
937 })
938}
939
940fn unity_clip_advice(
941 profile: &ResolvedProfile,
942 document: &animsmith_core::Document,
943 clips: &[EngineImportAdviceClipV1],
944) -> Result<Vec<UnityClipAdviceV1>, EngineImportAdviceError> {
945 let settings = settings_by_document_index(profile.clip_settings(), document)?;
946 let mut advice = clips
947 .iter()
948 .map(|clip| {
949 let row = settings
950 .get(clip.normalized_clip_index as usize)
951 .ok_or(EngineImportAdviceError::UnitySettingsMismatch)?;
952 Ok(UnityClipAdviceV1 {
953 normalized_clip_index: clip.normalized_clip_index,
954 lock_root_rotation: bake_setting(row.settings().get(&SettingId::RootRotation))?,
955 lock_root_height_y: bake_setting(row.settings().get(&SettingId::RootPositionY))?,
956 lock_root_position_xz: bake_setting(
957 row.settings().get(&SettingId::RootPositionXz),
958 )?,
959 })
960 })
961 .collect::<Result<Vec<_>, _>>()?;
962 advice.sort_by_key(|clip| clip.normalized_clip_index);
963 Ok(advice)
964}
965
966fn settings_by_document_index<'a>(
967 settings: &'a [ResolvedClipSettings],
968 document: &animsmith_core::Document,
969) -> Result<Vec<&'a ResolvedClipSettings>, EngineImportAdviceError> {
970 if settings.len() != document.clips.len() {
971 return Err(EngineImportAdviceError::UnitySettingsMismatch);
972 }
973 let mut used = vec![false; settings.len()];
974 document
975 .clips
976 .iter()
977 .map(|clip| {
978 let Some((index, row)) = settings
979 .iter()
980 .enumerate()
981 .find(|(index, row)| !used[*index] && row.clip_name() == clip.name)
982 else {
983 return Err(EngineImportAdviceError::UnitySettingsMismatch);
984 };
985 used[index] = true;
986 Ok(row)
987 })
988 .collect()
989}
990
991fn bool_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
992 match value {
993 Some(SettingValue::Boolean(value)) => Ok(*value),
994 _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
995 }
996}
997
998fn bake_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
999 match value {
1000 Some(SettingValue::BakeOrExtract(BakeOrExtract::Bake)) => Ok(true),
1001 Some(SettingValue::BakeOrExtract(BakeOrExtract::Extract)) => Ok(false),
1002 _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
1003 }
1004}
1005
1006fn movement_owner(value: MovementOwner) -> EngineImportAdviceMovementOwnerV1 {
1007 match value {
1008 MovementOwner::Gameplay => EngineImportAdviceMovementOwnerV1::Gameplay,
1009 MovementOwner::Animation => EngineImportAdviceMovementOwnerV1::Animation,
1010 }
1011}
1012
1013fn source_name(state: &SourceObservationStateV1<SourceTextV1>) -> EngineImportAdviceSourceNameV1 {
1014 match state {
1015 SourceObservationStateV1::Observed(value) => EngineImportAdviceSourceNameV1::Observed {
1016 value: value.as_str().to_owned(),
1017 },
1018 SourceObservationStateV1::ProvenAbsent => EngineImportAdviceSourceNameV1::ProvenAbsent,
1019 SourceObservationStateV1::Unavailable(reason) => {
1020 EngineImportAdviceSourceNameV1::Unavailable {
1021 reason: source_reason(*reason),
1022 }
1023 }
1024 }
1025}
1026
1027fn source_reason(reason: SourceUnavailableReasonV1) -> EngineImportAdviceSourceUnavailableReasonV1 {
1028 match reason {
1029 SourceUnavailableReasonV1::Malformed => {
1030 EngineImportAdviceSourceUnavailableReasonV1::Malformed
1031 }
1032 SourceUnavailableReasonV1::Discarded => {
1033 EngineImportAdviceSourceUnavailableReasonV1::Discarded
1034 }
1035 SourceUnavailableReasonV1::NormalizedAway => {
1036 EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway
1037 }
1038 SourceUnavailableReasonV1::BakedAway => {
1039 EngineImportAdviceSourceUnavailableReasonV1::BakedAway
1040 }
1041 SourceUnavailableReasonV1::LoaderUnsupported => {
1042 EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported
1043 }
1044 SourceUnavailableReasonV1::ProjectionBudgetExceeded => {
1045 EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded
1046 }
1047 SourceUnavailableReasonV1::ParserUnavailable => {
1048 EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable
1049 }
1050 }
1051}
1052
1053fn validate_optional(
1054 present: bool,
1055 availability: MeasurementAvailability,
1056) -> Result<(), EngineImportAdviceError> {
1057 if matches!(availability, MeasurementAvailability::Measured) != present {
1058 return Err(EngineImportAdviceError::InvalidMeasurement);
1059 }
1060 Ok(())
1061}
1062
1063pub const ENGINE_IMPORT_ADVICE_V2_ID: &str = "urn:animsmith:schema:engine-import-advice:2";
1069pub const ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION: u32 = 2;
1071pub const ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES: u64 = ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES;
1073
1074const ENGINE_IMPORT_ADVICE_V2_IDENTITY_DOMAIN: &str =
1075 "urn:animsmith:engine-import-advice-preimage:2";
1076const IMPORT_SETTING_PROJECTION_SCOPE: &str = "import-setting-projection";
1077const GODOT_V2_FACTS: (&str, u64) = (
1078 "5911192e94b514bc1a82a3914def9444a45b7f4999e8976585e28af6288833d1",
1079 1571,
1080);
1081const UNREAL_V2_FACTS: (&str, u64) = (
1082 "77df5554413b29767fb8d7f4f8d65d199ea54da80ae08e8c006128eb6cf9e329",
1083 1393,
1084);
1085
1086#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1088#[serde(rename_all = "snake_case")]
1089pub enum EngineImportAdviceStateV2 {
1090 Available,
1092 Refused,
1094}
1095
1096#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1098#[serde(rename_all = "snake_case")]
1099pub enum EngineImportAdviceRefusalReasonV2 {
1100 DependencyClosureIncomplete,
1102}
1103
1104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1106#[serde(rename_all = "snake_case")]
1107pub enum EngineImportAdviceProjectionValueV2 {
1108 Boolean(bool),
1110 UnsignedInteger(u32),
1112}
1113
1114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1116#[serde(deny_unknown_fields)]
1117pub struct EngineImportAdviceProjectionFieldV2 {
1118 pub key: String,
1120 pub value: EngineImportAdviceProjectionValueV2,
1122 pub value_origin: animsmith_core::EngineSettingValueOriginV3,
1124}
1125
1126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1128#[serde(deny_unknown_fields)]
1129pub struct EngineImportAdviceProjectionV2 {
1130 pub projection_kind: animsmith_core::ImportSettingProjectionKindV1,
1132 #[serde(deserialize_with = "deserialize_v2_projection_fields")]
1134 pub fields: Vec<EngineImportAdviceProjectionFieldV2>,
1135}
1136
1137impl EngineImportAdviceProjectionV2 {
1138 fn validate(&self) -> Result<(), EngineImportAdviceError> {
1139 if self.fields.is_empty() || self.fields.len() > MAX_CLIPS {
1140 return Err(EngineImportAdviceError::InvalidV2Projection);
1141 }
1142 if self
1143 .fields
1144 .iter()
1145 .any(|field| field.key.is_empty() || field.key.len() > MAX_TEXT_BYTES)
1146 || self
1147 .fields
1148 .windows(2)
1149 .any(|pair| pair[0].key >= pair[1].key)
1150 {
1151 return Err(EngineImportAdviceError::InvalidV2Projection);
1152 }
1153 Ok(())
1154 }
1155}
1156
1157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1159#[serde(transparent)]
1160pub struct EngineImportAdviceIdentityV2(InputIdentity);
1161
1162impl EngineImportAdviceIdentityV2 {
1163 pub const fn input_identity(&self) -> &InputIdentity {
1165 &self.0
1166 }
1167}
1168
1169#[derive(Debug, Clone, Serialize)]
1171pub struct EngineImportAdviceV2 {
1172 schema_version: u32,
1173 schema: &'static str,
1174 tool: ToolInfo,
1175 command: &'static str,
1176 identity: EngineImportAdviceIdentityV2,
1177 prediction_provenance: animsmith_core::PredictionProvenanceV4,
1178 basis: animsmith_core::EnginePredictionBasisV4,
1179 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 projection: Option<EngineImportAdviceProjectionV2>,
1181 state: EngineImportAdviceStateV2,
1182 #[serde(default, skip_serializing_if = "Option::is_none")]
1183 refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1184}
1185
1186impl EngineImportAdviceV2 {
1187 pub fn supports_profile(profile: &crate::EngineProfileV2) -> bool {
1190 let selection = profile.selection();
1191 let expected = match (
1192 selection.family(),
1193 selection.profile_revision(),
1194 selection.engine_version(),
1195 selection.importer(),
1196 ) {
1197 ("godot", 2, "4.7", "resource-importer-scene") => {
1198 ("urn:animsmith:engine-profile:godot:2", "godot_params")
1199 }
1200 ("unreal", 2, "5.8", "fbx-importer") => (
1201 "urn:animsmith:engine-profile:unreal:2",
1202 "unreal_fbx_import_data",
1203 ),
1204 _ => return false,
1205 };
1206 let Ok(core) = crate::project_engine_profile_v2(profile) else {
1207 return false;
1208 };
1209 let facts_identity_matches = match expected.1 {
1210 "godot_params" => {
1211 core.facts_identity().sha256() == GODOT_V2_FACTS.0
1212 && core.facts_identity().bytes() == GODOT_V2_FACTS.1
1213 }
1214 "unreal_fbx_import_data" => {
1215 core.facts_identity().sha256() == UNREAL_V2_FACTS.0
1216 && core.facts_identity().bytes() == UNREAL_V2_FACTS.1
1217 }
1218 _ => false,
1219 };
1220 profile.profile_urn() == expected.0
1221 && facts_identity_matches
1222 && matches!(
1223 profile
1224 .facts()
1225 .iter()
1226 .find(|fact| {
1227 fact.id() == animsmith_core::EngineFactIdV2::ImportSettingProjection
1228 })
1229 .map(|fact| fact.state()),
1230 Some(animsmith_core::EngineFactStateV2::Known(
1231 animsmith_core::EngineFactValueV2::Token(token)
1232 )) if token == expected.1
1233 )
1234 }
1235
1236 pub fn from_source(
1241 tool: ToolInfo,
1242 source: &LoadedSource,
1243 profile: &crate::ResolvedProfileSettingsV2,
1244 ) -> Result<Self, EngineImportAdviceError> {
1245 if !Self::supports_profile(profile.profile()) {
1246 return Err(EngineImportAdviceError::UnsupportedProfileV2);
1247 }
1248 let provenance = project_prediction_provenance_v4(profile, source, Vec::new())
1249 .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1250 let basis = v2_basis(&provenance)?;
1251 let (state, refusal_reason, projection) =
1252 if source.dependency_closure().coverage().is_complete() {
1253 let projection = v2_projection(&provenance)?;
1254 projection.validate()?;
1255 (EngineImportAdviceStateV2::Available, None, Some(projection))
1256 } else {
1257 (
1258 EngineImportAdviceStateV2::Refused,
1259 Some(EngineImportAdviceRefusalReasonV2::DependencyClosureIncomplete),
1260 None,
1261 )
1262 };
1263 let mut report = Self {
1264 schema_version: ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION,
1265 schema: ENGINE_IMPORT_ADVICE_V2_ID,
1266 tool,
1267 command: ENGINE_IMPORT_ADVICE_COMMAND,
1268 identity: EngineImportAdviceIdentityV2(InputIdentity::from_bytes(&[])),
1269 prediction_provenance: provenance,
1270 basis,
1271 projection,
1272 state,
1273 refusal_reason,
1274 };
1275 report.validate_without_identity()?;
1276 report.identity = EngineImportAdviceIdentityV2(report.computed_identity());
1277 Ok(report)
1278 }
1279
1280 pub const fn identity(&self) -> &EngineImportAdviceIdentityV2 {
1282 &self.identity
1283 }
1284 pub const fn prediction_provenance(&self) -> &animsmith_core::PredictionProvenanceV4 {
1286 &self.prediction_provenance
1287 }
1288 pub const fn basis(&self) -> &animsmith_core::EnginePredictionBasisV4 {
1290 &self.basis
1291 }
1292 pub const fn projection(&self) -> Option<&EngineImportAdviceProjectionV2> {
1294 self.projection.as_ref()
1295 }
1296 pub const fn state(&self) -> EngineImportAdviceStateV2 {
1298 self.state
1299 }
1300 pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV2> {
1302 self.refusal_reason
1303 }
1304
1305 fn validate_without_identity(&self) -> Result<(), EngineImportAdviceError> {
1306 if self.schema_version != ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION
1307 || self.schema != ENGINE_IMPORT_ADVICE_V2_ID
1308 || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1309 {
1310 return Err(EngineImportAdviceError::WrongV2Header);
1311 }
1312 self.prediction_provenance
1313 .validate()
1314 .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1315 validate_v2_semantics(
1316 &self.prediction_provenance,
1317 &self.basis,
1318 self.projection.as_ref(),
1319 self.state,
1320 self.refusal_reason,
1321 )?;
1322 Ok(())
1323 }
1324
1325 fn computed_identity(&self) -> InputIdentity {
1326 let mut encoder = AdviceEncoder::new(ENGINE_IMPORT_ADVICE_V2_IDENTITY_DOMAIN);
1327 encode_identity(
1328 &mut encoder,
1329 self.prediction_provenance.identity().input_identity(),
1330 );
1331 encoder.token(
1332 &serde_json::to_string(&self.basis).expect("V2 basis serialization is infallible"),
1333 );
1334 encoder.bool(self.projection.is_some());
1335 if let Some(projection) = self.projection.as_ref() {
1336 encoder.token(
1337 &serde_json::to_string(projection)
1338 .expect("V2 projection serialization is infallible"),
1339 );
1340 }
1341 encoder.token(match self.state {
1342 EngineImportAdviceStateV2::Available => "available",
1343 EngineImportAdviceStateV2::Refused => "refused",
1344 });
1345 encoder.bool(self.refusal_reason.is_some());
1346 if let Some(reason) = self.refusal_reason {
1347 encoder.token(v2_refusal_name(reason));
1348 }
1349 InputIdentity::from_bytes(&encoder.bytes)
1350 }
1351}
1352
1353#[derive(Debug, Deserialize)]
1355#[serde(deny_unknown_fields)]
1356pub struct EngineImportAdviceInputV2 {
1357 schema_version: u32,
1358 schema: String,
1359 tool: ToolInputV1,
1360 command: String,
1361 identity: EngineImportAdviceIdentityV2,
1362 prediction_provenance: Box<RawValue>,
1363 basis: Box<RawValue>,
1364 #[serde(default, deserialize_with = "deserialize_present_non_null")]
1365 projection: Option<EngineImportAdviceProjectionV2>,
1366 state: EngineImportAdviceStateV2,
1367 #[serde(default, deserialize_with = "deserialize_present_non_null")]
1368 refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1369}
1370
1371impl EngineImportAdviceInputV2 {
1372 pub fn read_from(reader: impl Read) -> Result<Self, EngineImportAdviceReadError> {
1374 let mut bytes = Vec::new();
1375 reader
1376 .take(ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES.saturating_add(1))
1377 .read_to_end(&mut bytes)
1378 .map_err(|source| EngineImportAdviceReadError::Io { source })?;
1379 if bytes.len() as u64 > ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES {
1380 return Err(EngineImportAdviceReadError::ReportTooLarge {
1381 limit: ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES,
1382 });
1383 }
1384 serde_json::from_slice(&bytes)
1385 .map_err(|source| EngineImportAdviceReadError::InvalidJson { source })
1386 }
1387
1388 pub fn into_report(self) -> Result<EngineImportAdviceReadbackV2, EngineImportAdviceError> {
1390 if self.schema_version != ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION
1391 || self.schema != ENGINE_IMPORT_ADVICE_V2_ID
1392 || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1393 {
1394 return Err(EngineImportAdviceError::WrongV2Header);
1395 }
1396 validate_tool(&self.tool)?;
1397 let prediction_provenance: animsmith_core::PredictionProvenanceV4 =
1398 serde_json::from_str(self.prediction_provenance.get())
1399 .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1400 let basis: animsmith_core::EnginePredictionBasisV4 = serde_json::from_str(self.basis.get())
1401 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?;
1402 let report = EngineImportAdviceReadbackV2 {
1403 tool: EngineImportAdviceToolReadbackV1 {
1404 name: self.tool.name,
1405 version: self.tool.version,
1406 revision: required_nullable_value(self.tool.source.revision),
1407 dirty: required_nullable_value(self.tool.source.dirty),
1408 },
1409 identity: self.identity,
1410 prediction_provenance,
1411 basis,
1412 projection: self.projection,
1413 state: self.state,
1414 refusal_reason: self.refusal_reason,
1415 };
1416 report.validate()?;
1417 Ok(report)
1418 }
1419}
1420
1421#[derive(Debug, Clone)]
1423pub struct EngineImportAdviceReadbackV2 {
1424 tool: EngineImportAdviceToolReadbackV1,
1425 identity: EngineImportAdviceIdentityV2,
1426 prediction_provenance: animsmith_core::PredictionProvenanceV4,
1427 basis: animsmith_core::EnginePredictionBasisV4,
1428 projection: Option<EngineImportAdviceProjectionV2>,
1429 state: EngineImportAdviceStateV2,
1430 refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1431}
1432
1433impl EngineImportAdviceReadbackV2 {
1434 pub const fn tool(&self) -> &EngineImportAdviceToolReadbackV1 {
1436 &self.tool
1437 }
1438 pub const fn identity(&self) -> &EngineImportAdviceIdentityV2 {
1440 &self.identity
1441 }
1442 pub const fn prediction_provenance(&self) -> &animsmith_core::PredictionProvenanceV4 {
1444 &self.prediction_provenance
1445 }
1446 pub const fn basis(&self) -> &animsmith_core::EnginePredictionBasisV4 {
1448 &self.basis
1449 }
1450 pub const fn projection(&self) -> Option<&EngineImportAdviceProjectionV2> {
1452 self.projection.as_ref()
1453 }
1454 pub const fn state(&self) -> EngineImportAdviceStateV2 {
1456 self.state
1457 }
1458 pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV2> {
1460 self.refusal_reason
1461 }
1462
1463 fn validate(&self) -> Result<(), EngineImportAdviceError> {
1464 self.prediction_provenance
1465 .validate()
1466 .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1467 validate_v2_semantics(
1468 &self.prediction_provenance,
1469 &self.basis,
1470 self.projection.as_ref(),
1471 self.state,
1472 self.refusal_reason,
1473 )?;
1474 let surrogate = EngineImportAdviceV2 {
1475 schema_version: ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION,
1476 schema: ENGINE_IMPORT_ADVICE_V2_ID,
1477 tool: ToolInfo::animsmith(animsmith_core::ToolSource::new(None, None)),
1478 command: ENGINE_IMPORT_ADVICE_COMMAND,
1479 identity: self.identity.clone(),
1480 prediction_provenance: self.prediction_provenance.clone(),
1481 basis: self.basis.clone(),
1482 projection: self.projection.clone(),
1483 state: self.state,
1484 refusal_reason: self.refusal_reason,
1485 };
1486 if surrogate.computed_identity() != self.identity.0 {
1487 return Err(EngineImportAdviceError::IdentityMismatch);
1488 }
1489 Ok(())
1490 }
1491}
1492
1493fn v2_supporting_sources(
1494 provenance: &animsmith_core::PredictionProvenanceV4,
1495) -> Vec<&animsmith_core::EnginePrimarySourceV2> {
1496 let required = provenance
1497 .settings()
1498 .document_settings()
1499 .iter()
1500 .map(|row| row.id())
1501 .collect::<BTreeSet<_>>();
1502 provenance
1503 .profile()
1504 .primary_sources()
1505 .iter()
1506 .filter(|source| {
1507 source
1508 .supported_fact_ids()
1509 .contains(&animsmith_core::EngineFactIdV2::ImportSettingProjection)
1510 && required
1511 .iter()
1512 .all(|id| source.supported_setting_ids().contains(id))
1513 })
1514 .collect()
1515}
1516
1517fn v2_basis(
1518 provenance: &animsmith_core::PredictionProvenanceV4,
1519) -> Result<animsmith_core::EnginePredictionBasisV4, EngineImportAdviceError> {
1520 use animsmith_core::{PredictionBasisReferenceV1 as Ref, PredictionBasisReferenceV2 as Ref2};
1521 let mut references = Vec::new();
1522 references.push(animsmith_core::PredictionBasisReferenceV4::v2(Ref2::v1(
1523 Ref::profile_fact("import_setting_projection")
1524 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?,
1525 )));
1526 for row in provenance.settings().document_settings() {
1527 references.push(animsmith_core::PredictionBasisReferenceV4::v2(Ref2::v1(
1528 Ref::resolved_setting(
1529 animsmith_core::ResolvedSettingLocationV1::Document,
1530 row.id().as_str(),
1531 )
1532 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?,
1533 )));
1534 }
1535 for source in v2_supporting_sources(provenance) {
1536 references.push(animsmith_core::PredictionBasisReferenceV4::v2(Ref2::v1(
1537 Ref::primary_source(source.id())
1538 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?,
1539 )));
1540 }
1541 animsmith_core::EnginePredictionBasisV4::new(references)
1542 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))
1543}
1544
1545fn v2_projection(
1546 provenance: &animsmith_core::PredictionProvenanceV4,
1547) -> Result<EngineImportAdviceProjectionV2, EngineImportAdviceError> {
1548 use animsmith_core::{EngineSettingIdV2 as Id, EngineSettingValueV2 as Value};
1549 let family = provenance.profile().selection().family();
1550 let expected_token = match family {
1551 "godot" => Some("godot_params"),
1552 "unreal" => Some("unreal_fbx_import_data"),
1553 _ => None,
1554 };
1555 if !matches!(
1556 (expected_token, provenance.profile().fact(animsmith_core::EngineFactIdV2::ImportSettingProjection)),
1557 (Some(expected), Some(fact))
1558 if matches!(fact.state(), animsmith_core::EngineFactStateV2::Known(animsmith_core::EngineFactValueV2::Token(token)) if token == expected)
1559 ) {
1560 return Err(EngineImportAdviceError::InvalidV2Projection);
1561 }
1562 let mut fields = Vec::new();
1563 match (
1564 family,
1565 provenance.profile().selection().profile_revision(),
1566 provenance.profile().selection().engine_version(),
1567 provenance.profile().selection().importer(),
1568 provenance.source_format(),
1569 ) {
1570 (
1571 "godot",
1572 2,
1573 "4.7",
1574 "resource-importer-scene",
1575 animsmith_core::SourceFormatV1::Glb | animsmith_core::SourceFormatV1::GltfJson,
1576 ) => {
1577 let fps = provenance
1578 .settings()
1579 .document_setting(Id::AnimationFps)
1580 .ok_or(EngineImportAdviceError::InvalidV2Settings)?;
1581 let trimming = provenance
1582 .settings()
1583 .document_setting(Id::AnimationTrimming)
1584 .ok_or(EngineImportAdviceError::InvalidV2Settings)?;
1585 let Value::PositiveInteger(fps_value) = fps.value() else {
1586 return Err(EngineImportAdviceError::InvalidV2Settings);
1587 };
1588 let Value::Boolean(trimming_value) = trimming.value() else {
1589 return Err(EngineImportAdviceError::InvalidV2Settings);
1590 };
1591 fields.push(EngineImportAdviceProjectionFieldV2 {
1592 key: "animation/fps".into(),
1593 value: EngineImportAdviceProjectionValueV2::UnsignedInteger(*fps_value),
1594 value_origin: fps.value_origin(),
1595 });
1596 fields.push(EngineImportAdviceProjectionFieldV2 {
1597 key: "animation/trimming".into(),
1598 value: EngineImportAdviceProjectionValueV2::Boolean(*trimming_value),
1599 value_origin: trimming.value_origin(),
1600 });
1601 fields.sort_by(|left, right| left.key.cmp(&right.key));
1602 Ok(EngineImportAdviceProjectionV2 {
1603 projection_kind: animsmith_core::ImportSettingProjectionKindV1::GodotParams,
1604 fields,
1605 })
1606 }
1607 ("unreal", 2, "5.8", "fbx-importer", animsmith_core::SourceFormatV1::Fbx) => {
1608 let sample_rate = provenance
1609 .settings()
1610 .document_setting(Id::SampleRate)
1611 .ok_or(EngineImportAdviceError::InvalidV2Settings)?;
1612 let Value::SampleRate(value) = sample_rate.value() else {
1613 return Err(EngineImportAdviceError::InvalidV2Settings);
1614 };
1615 let (use_default, custom) = match value {
1616 animsmith_core::EngineSampleRateV2::Default30 => (true, None),
1617 animsmith_core::EngineSampleRateV2::SourceDetermined => (false, Some(0)),
1618 animsmith_core::EngineSampleRateV2::CustomHz(value) => (false, Some(*value)),
1619 };
1620 fields.push(EngineImportAdviceProjectionFieldV2 {
1621 key: "bUseDefaultSampleRate".into(),
1622 value: EngineImportAdviceProjectionValueV2::Boolean(use_default),
1623 value_origin: sample_rate.value_origin(),
1624 });
1625 if let Some(custom) = custom {
1626 fields.push(EngineImportAdviceProjectionFieldV2 {
1627 key: "CustomSampleRate".into(),
1628 value: EngineImportAdviceProjectionValueV2::UnsignedInteger(custom),
1629 value_origin: sample_rate.value_origin(),
1630 });
1631 }
1632 fields.sort_by(|left, right| left.key.cmp(&right.key));
1633 Ok(EngineImportAdviceProjectionV2 {
1634 projection_kind: animsmith_core::ImportSettingProjectionKindV1::UnrealFbxImportData,
1635 fields,
1636 })
1637 }
1638 _ => Err(EngineImportAdviceError::UnsupportedProfileV2),
1639 }
1640}
1641
1642fn v2_refusal_name(reason: EngineImportAdviceRefusalReasonV2) -> &'static str {
1643 match reason {
1644 EngineImportAdviceRefusalReasonV2::DependencyClosureIncomplete => {
1645 "dependency_closure_incomplete"
1646 }
1647 }
1648}
1649
1650fn validate_v2_semantics(
1651 provenance: &animsmith_core::PredictionProvenanceV4,
1652 basis: &animsmith_core::EnginePredictionBasisV4,
1653 projection: Option<&EngineImportAdviceProjectionV2>,
1654 state: EngineImportAdviceStateV2,
1655 refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1656) -> Result<(), EngineImportAdviceError> {
1657 validate_v2_profile(provenance)?;
1658 validate_v2_basis(provenance, basis)?;
1659 if basis != &v2_basis(provenance)? {
1660 return Err(EngineImportAdviceError::InvalidV2Projection);
1661 }
1662 let expected_projection = v2_projection(provenance)?;
1663 expected_projection.validate()?;
1664 if let Some(projection) = projection {
1665 projection.validate()?;
1666 }
1667 if provenance.dependency_closure().coverage().is_complete() {
1668 if state != EngineImportAdviceStateV2::Available
1669 || projection != Some(&expected_projection)
1670 || refusal_reason.is_some()
1671 || !provenance
1672 .profile()
1673 .accepts_format(provenance.source_format())
1674 || v2_supporting_sources(provenance).is_empty()
1675 {
1676 return Err(EngineImportAdviceError::InvalidV2Lifecycle);
1677 }
1678 } else if state != EngineImportAdviceStateV2::Refused
1679 || projection.is_some()
1680 || refusal_reason != Some(EngineImportAdviceRefusalReasonV2::DependencyClosureIncomplete)
1681 {
1682 return Err(EngineImportAdviceError::InvalidV2Lifecycle);
1683 }
1684 Ok(())
1685}
1686
1687fn validate_v2_profile(
1688 provenance: &animsmith_core::PredictionProvenanceV4,
1689) -> Result<(), EngineImportAdviceError> {
1690 let selection = provenance.profile().selection();
1691 let expected = match (
1692 selection.family(),
1693 selection.profile_revision(),
1694 selection.engine_version(),
1695 selection.importer(),
1696 ) {
1697 ("godot", 2, "4.7", "resource-importer-scene") => (
1698 "urn:animsmith:engine-profile:godot:2",
1699 GODOT_V2_FACTS,
1700 "godot_params",
1701 ),
1702 ("unreal", 2, "5.8", "fbx-importer") => (
1703 "urn:animsmith:engine-profile:unreal:2",
1704 UNREAL_V2_FACTS,
1705 "unreal_fbx_import_data",
1706 ),
1707 _ => return Err(EngineImportAdviceError::UnsupportedProfileV2),
1708 };
1709 if provenance.profile().fact_bundle_urn() != expected.0
1710 || provenance.profile().facts_identity().sha256() != expected.1.0
1711 || provenance.profile().facts_identity().bytes() != expected.1.1
1712 || !matches!(
1713 provenance
1714 .profile()
1715 .fact(animsmith_core::EngineFactIdV2::ImportSettingProjection),
1716 Some(fact)
1717 if matches!(fact.state(), animsmith_core::EngineFactStateV2::Known(animsmith_core::EngineFactValueV2::Token(token)) if token == expected.2)
1718 )
1719 {
1720 return Err(EngineImportAdviceError::InvalidV2Projection);
1721 }
1722 Ok(())
1723}
1724
1725fn validate_v2_basis(
1726 provenance: &animsmith_core::PredictionProvenanceV4,
1727 basis: &animsmith_core::EnginePredictionBasisV4,
1728) -> Result<(), EngineImportAdviceError> {
1729 let facet = animsmith_core::EnginePredictionFacetV4::required_unavailable(
1733 animsmith_core::EvaluationScope::new(animsmith_core::EvaluationScopeCode::custom(
1734 IMPORT_SETTING_PROJECTION_SCOPE,
1735 )),
1736 basis.clone(),
1737 vec![animsmith_core::PredictionUnavailableReasonV2::ProfileFactUnknown],
1738 )
1739 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?;
1740 let prediction =
1741 animsmith_core::EnginePredictionV4::new(provenance.identity().clone(), vec![facet])
1742 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?;
1743 prediction
1744 .validate_against_provenance(provenance)
1745 .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))
1746}
1747
1748fn validate_unity_document_against_provenance(
1749 provenance: &PredictionProvenanceV1,
1750 payload: &EngineImportAdvicePayloadV1,
1751) -> Result<(), EngineImportAdviceError> {
1752 use animsmith_core::{EngineSettingIdV1 as Id, EngineSettingValueV1 as Value};
1753 let Some(document) = payload.unity_document() else {
1754 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1755 };
1756 let settings = provenance.settings();
1757 if settings.document_setting(Id::ConvertUnits) != Some(&Value::Boolean(document.convert_units))
1758 || settings.document_setting(Id::BakeAxisConversion)
1759 != Some(&Value::Boolean(document.bake_axis_conversion))
1760 {
1761 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1762 }
1763 let generic = matches!(payload, EngineImportAdvicePayloadV1::UnityGeneric { .. });
1764 match (
1765 generic,
1766 document.root_motion_source.as_ref(),
1767 settings.document_setting(Id::RootMotionSource),
1768 ) {
1769 (true, Some(expected), Some(Value::SourceTransformPath(actual))) if expected == actual => {
1770 Ok(())
1771 }
1772 (false, None, None) => Ok(()),
1773 _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
1774 }
1775}
1776
1777fn validate_unity_settings_against_provenance(
1778 provenance: &PredictionProvenanceV1,
1779 clips: &[EngineImportAdviceClipV1],
1780 payload: &EngineImportAdvicePayloadV1,
1781) -> Result<(), EngineImportAdviceError> {
1782 use animsmith_core::{
1783 EngineBakeOrExtractV1 as Bake, EngineSettingIdV1 as Id, EngineSettingValueV1 as Value,
1784 };
1785 validate_unity_document_against_provenance(provenance, payload)?;
1786 let settings = provenance.settings();
1787 if settings.clips().len() != clips.len() {
1788 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1789 }
1790 let mut used = vec![false; settings.clips().len()];
1791 for advice in payload.unity_clips() {
1792 let Some(clip) = clips
1793 .iter()
1794 .find(|clip| clip.normalized_clip_index == advice.normalized_clip_index)
1795 else {
1796 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1797 };
1798 let Some((index, row)) = settings
1799 .clips()
1800 .iter()
1801 .enumerate()
1802 .find(|(index, row)| !used[*index] && row.clip_name() == clip.normalized_clip_name)
1803 else {
1804 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1805 };
1806 used[index] = true;
1807 for (id, baked) in [
1808 (Id::RootRotation, advice.lock_root_rotation),
1809 (Id::RootPositionY, advice.lock_root_height_y),
1810 (Id::RootPositionXz, advice.lock_root_position_xz),
1811 ] {
1812 let expected = Value::BakeOrExtract(if baked { Bake::Bake } else { Bake::Extract });
1813 if row.setting(id) != Some(&expected) {
1814 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1815 }
1816 }
1817 }
1818 Ok(())
1819}
1820
1821fn validate_profile(
1822 provenance: &PredictionProvenanceV1,
1823 payload: &EngineImportAdvicePayloadV1,
1824) -> Result<(), EngineImportAdviceError> {
1825 let selection = provenance.profile().selection();
1826 let facts = provenance.profile().facts_identity();
1827 let (family, revision, version, importer, identity) = match payload {
1828 EngineImportAdvicePayloadV1::UnityGeneric { .. } => (
1829 "unity-generic",
1830 1,
1831 "6000.3",
1832 "fbx-model-importer",
1833 UNITY_GENERIC_FACTS,
1834 ),
1835 EngineImportAdvicePayloadV1::UnityHumanoid { .. } => (
1836 "unity-humanoid",
1837 1,
1838 "6000.3",
1839 "fbx-model-importer",
1840 UNITY_HUMANOID_FACTS,
1841 ),
1842 EngineImportAdvicePayloadV1::Unreal => ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
1843 EngineImportAdvicePayloadV1::Godot => {
1844 ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS)
1845 }
1846 };
1847 if selection.family() != family
1848 || selection.profile_revision() != revision
1849 || selection.engine_version() != version
1850 || selection.importer() != importer
1851 || facts.sha256() != identity.0
1852 || facts.bytes() != identity.1
1853 {
1854 return Err(EngineImportAdviceError::ProfileMismatch);
1855 }
1856 Ok(())
1857}
1858
1859#[derive(Debug, Deserialize)]
1860#[serde(deny_unknown_fields)]
1861struct ToolSourceInputV1 {
1862 #[serde(default, deserialize_with = "deserialize_required_nullable")]
1863 revision: RequiredNullable<String>,
1864 #[serde(default, deserialize_with = "deserialize_required_nullable")]
1865 dirty: RequiredNullable<bool>,
1866}
1867
1868#[derive(Debug, Default)]
1869enum RequiredNullable<T> {
1870 #[default]
1871 Missing,
1872 Present(Option<T>),
1873}
1874
1875fn deserialize_required_nullable<'de, D, T>(
1876 deserializer: D,
1877) -> Result<RequiredNullable<T>, D::Error>
1878where
1879 D: Deserializer<'de>,
1880 T: Deserialize<'de>,
1881{
1882 Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
1883}
1884
1885fn deserialize_present_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1886where
1887 D: Deserializer<'de>,
1888 T: Deserialize<'de>,
1889{
1890 T::deserialize(deserializer).map(Some)
1891}
1892
1893#[derive(Debug, Deserialize)]
1894#[serde(deny_unknown_fields)]
1895struct ToolInputV1 {
1896 name: String,
1897 version: String,
1898 source: ToolSourceInputV1,
1899}
1900
1901#[derive(Debug, Deserialize)]
1903#[serde(deny_unknown_fields)]
1904pub struct EngineImportAdviceInput {
1905 schema_version: u32,
1906 schema: String,
1907 tool: ToolInputV1,
1908 command: String,
1909 identity: EngineImportAdviceIdentityV1,
1910 prediction_provenance: Box<RawValue>,
1911 state: EngineImportAdviceStateV1,
1912 #[serde(default, deserialize_with = "deserialize_present_non_null")]
1913 refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1914 #[serde(deserialize_with = "deserialize_clips")]
1915 clips: Vec<EngineImportAdviceClipV1>,
1916 payload: EngineImportAdvicePayloadV1,
1917}
1918
1919impl EngineImportAdviceInput {
1920 pub fn read_from(reader: impl Read) -> Result<Self, EngineImportAdviceReadError> {
1926 Self::read_from_with_limit(reader, ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES)
1927 }
1928
1929 fn read_from_with_limit(
1930 reader: impl Read,
1931 limit: u64,
1932 ) -> Result<Self, EngineImportAdviceReadError> {
1933 let mut bytes = Vec::new();
1934 reader
1935 .take(limit.saturating_add(1))
1936 .read_to_end(&mut bytes)
1937 .map_err(|source| EngineImportAdviceReadError::Io { source })?;
1938 if bytes.len() as u64 > limit {
1939 return Err(EngineImportAdviceReadError::ReportTooLarge { limit });
1940 }
1941 serde_json::from_slice(&bytes)
1942 .map_err(|source| EngineImportAdviceReadError::InvalidJson { source })
1943 }
1944
1945 pub fn into_report(self) -> Result<EngineImportAdviceReadbackV1, EngineImportAdviceError> {
1951 if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
1952 || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
1953 || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1954 {
1955 return Err(EngineImportAdviceError::WrongHeader);
1956 }
1957 validate_tool(&self.tool)?;
1958 let prediction_provenance: PredictionProvenanceV1 =
1959 serde_json::from_str(self.prediction_provenance.get())
1960 .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
1961 let report = EngineImportAdviceReadbackV1 {
1962 tool: EngineImportAdviceToolReadbackV1 {
1963 name: self.tool.name,
1964 version: self.tool.version,
1965 revision: required_nullable_value(self.tool.source.revision),
1966 dirty: required_nullable_value(self.tool.source.dirty),
1967 },
1968 identity: self.identity,
1969 prediction_provenance,
1970 state: self.state,
1971 refusal_reason: self.refusal_reason,
1972 clips: self.clips,
1973 payload: self.payload,
1974 };
1975 report.validate()?;
1976 Ok(report)
1977 }
1978}
1979
1980#[derive(Debug, Clone)]
1982pub struct EngineImportAdviceReadbackV1 {
1983 tool: EngineImportAdviceToolReadbackV1,
1984 identity: EngineImportAdviceIdentityV1,
1985 prediction_provenance: PredictionProvenanceV1,
1986 state: EngineImportAdviceStateV1,
1987 refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1988 clips: Vec<EngineImportAdviceClipV1>,
1989 payload: EngineImportAdvicePayloadV1,
1990}
1991
1992impl EngineImportAdviceReadbackV1 {
1993 pub const fn tool(&self) -> &EngineImportAdviceToolReadbackV1 {
1995 &self.tool
1996 }
1997 pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
1999 &self.identity
2000 }
2001 pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
2003 &self.prediction_provenance
2004 }
2005 pub const fn state(&self) -> EngineImportAdviceStateV1 {
2007 self.state
2008 }
2009 pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
2011 self.refusal_reason
2012 }
2013 pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
2015 &self.clips
2016 }
2017 pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
2019 &self.payload
2020 }
2021
2022 fn validate(&self) -> Result<(), EngineImportAdviceError> {
2023 let surrogate = EngineImportAdviceV1 {
2024 schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
2025 schema: ENGINE_IMPORT_ADVICE_V1_ID,
2026 tool: ToolInfo::animsmith(animsmith_core::ToolSource::new(None, None)),
2027 command: ENGINE_IMPORT_ADVICE_COMMAND,
2028 identity: self.identity.clone(),
2029 prediction_provenance: self.prediction_provenance.clone(),
2030 state: self.state,
2031 refusal_reason: self.refusal_reason,
2032 clips: self.clips.clone(),
2033 payload: self.payload.clone(),
2034 };
2035 surrogate.validate_without_identity()?;
2036 if surrogate.computed_identity() != self.identity.0 {
2037 return Err(EngineImportAdviceError::IdentityMismatch);
2038 }
2039 Ok(())
2040 }
2041}
2042
2043#[derive(Debug, Clone, PartialEq, Eq)]
2045pub struct EngineImportAdviceToolReadbackV1 {
2046 name: String,
2047 version: String,
2048 revision: Option<String>,
2049 dirty: Option<bool>,
2050}
2051
2052impl EngineImportAdviceToolReadbackV1 {
2053 pub fn name(&self) -> &str {
2055 &self.name
2056 }
2057 pub fn version(&self) -> &str {
2059 &self.version
2060 }
2061 pub fn revision(&self) -> Option<&str> {
2063 self.revision.as_deref()
2064 }
2065 pub const fn dirty(&self) -> Option<bool> {
2067 self.dirty
2068 }
2069}
2070
2071struct BoundedSequenceVisitor<T> {
2072 element: PhantomData<fn() -> T>,
2073}
2074
2075impl<'de, T> Visitor<'de> for BoundedSequenceVisitor<T>
2076where
2077 T: Deserialize<'de>,
2078{
2079 type Value = Vec<T>;
2080 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2081 write!(formatter, "a sequence with at most {MAX_CLIPS} clip rows")
2082 }
2083 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
2084 where
2085 A: SeqAccess<'de>,
2086 {
2087 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX_CLIPS));
2088 while values.len() < MAX_CLIPS {
2089 let Some(value) = sequence.next_element()? else {
2090 return Ok(values);
2091 };
2092 values.push(value);
2093 }
2094 if sequence.next_element::<serde::de::IgnoredAny>()?.is_some() {
2095 return Err(A::Error::custom(EngineImportAdviceError::TooManyClips {
2096 found: MAX_CLIPS + 1,
2097 limit: MAX_CLIPS,
2098 }));
2099 }
2100 Ok(values)
2101 }
2102}
2103
2104fn deserialize_bounded_clips<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
2105where
2106 D: Deserializer<'de>,
2107 T: Deserialize<'de>,
2108{
2109 deserializer.deserialize_seq(BoundedSequenceVisitor {
2110 element: PhantomData,
2111 })
2112}
2113fn deserialize_clips<'de, D>(deserializer: D) -> Result<Vec<EngineImportAdviceClipV1>, D::Error>
2114where
2115 D: Deserializer<'de>,
2116{
2117 deserialize_bounded_clips(deserializer)
2118}
2119fn deserialize_unity_clips<'de, D>(deserializer: D) -> Result<Vec<UnityClipAdviceV1>, D::Error>
2120where
2121 D: Deserializer<'de>,
2122{
2123 deserialize_bounded_clips(deserializer)
2124}
2125
2126fn deserialize_v2_projection_fields<'de, D>(
2127 deserializer: D,
2128) -> Result<Vec<EngineImportAdviceProjectionFieldV2>, D::Error>
2129where
2130 D: Deserializer<'de>,
2131{
2132 deserialize_bounded_clips(deserializer)
2133}
2134
2135fn validate_tool(tool: &ToolInputV1) -> Result<(), EngineImportAdviceError> {
2136 if tool.name != "animsmith"
2137 || tool.name.len() > MAX_TEXT_BYTES
2138 || tool.version.len() > MAX_TEXT_BYTES
2139 || !is_schema_semver(&tool.version)
2140 || matches!(tool.source.revision, RequiredNullable::Missing)
2141 || matches!(tool.source.dirty, RequiredNullable::Missing)
2142 || matches!(
2143 &tool.source.revision,
2144 RequiredNullable::Present(Some(revision))
2145 if revision.len() != 40
2146 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit())
2147 )
2148 {
2149 return Err(EngineImportAdviceError::InvalidTool);
2150 }
2151 Ok(())
2152}
2153
2154fn required_nullable_value<T>(value: RequiredNullable<T>) -> Option<T> {
2155 match value {
2156 RequiredNullable::Present(value) => value,
2157 RequiredNullable::Missing => unreachable!("validated required nullable field"),
2158 }
2159}
2160
2161fn is_schema_semver(value: &str) -> bool {
2162 let without_build = match value.split_once('+') {
2163 Some((core, build))
2164 if !build.is_empty()
2165 && !build.contains('+')
2166 && build
2167 .bytes()
2168 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2169 {
2170 core
2171 }
2172 Some(_) => return false,
2173 None => value,
2174 };
2175 let core = match without_build.split_once('-') {
2176 Some((core, pre))
2177 if !pre.is_empty()
2178 && pre
2179 .bytes()
2180 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2181 {
2182 core
2183 }
2184 Some(_) => return false,
2185 None => without_build,
2186 };
2187 let parts = core.split('.').collect::<Vec<_>>();
2188 parts.len() == 3
2189 && parts.iter().all(|part| {
2190 !part.is_empty()
2191 && part.bytes().all(|byte| byte.is_ascii_digit())
2192 && (*part == "0" || !part.starts_with('0'))
2193 })
2194}
2195
2196struct AdviceEncoder {
2197 bytes: Vec<u8>,
2198}
2199impl AdviceEncoder {
2200 fn new(domain: &str) -> Self {
2201 let mut value = Self { bytes: Vec::new() };
2202 value.token(domain);
2203 value
2204 }
2205 fn token(&mut self, value: &str) {
2206 self.bytes
2207 .extend_from_slice(&(value.len() as u64).to_le_bytes());
2208 self.bytes.extend_from_slice(value.as_bytes());
2209 }
2210 fn count(&mut self, value: usize) {
2211 self.u64(value as u64);
2212 }
2213 fn u64(&mut self, value: u64) {
2214 self.bytes.extend_from_slice(&value.to_le_bytes());
2215 }
2216 fn bool(&mut self, value: bool) {
2217 self.bytes.push(u8::from(value));
2218 }
2219 fn f64(&mut self, value: f64) {
2220 self.bytes.extend_from_slice(&value.to_bits().to_le_bytes());
2221 }
2222}
2223
2224fn encode_identity(encoder: &mut AdviceEncoder, value: &InputIdentity) {
2225 encoder.token(value.sha256());
2226 encoder.u64(value.bytes());
2227}
2228fn encode_string_option(encoder: &mut AdviceEncoder, value: Option<&str>) {
2229 encoder.bool(value.is_some());
2230 if let Some(value) = value {
2231 encoder.token(value);
2232 }
2233}
2234fn encode_clip(encoder: &mut AdviceEncoder, clip: &EngineImportAdviceClipV1) {
2235 encoder.u64(clip.source_clip_index);
2236 encoder.u64(clip.normalized_clip_index);
2237 encoder.token(&clip.normalized_clip_name);
2238 match &clip.source_name {
2239 EngineImportAdviceSourceNameV1::Observed { value } => {
2240 encoder.token("observed");
2241 encoder.token(value);
2242 }
2243 EngineImportAdviceSourceNameV1::ProvenAbsent => encoder.token("proven_absent"),
2244 EngineImportAdviceSourceNameV1::Unavailable { reason } => {
2245 encoder.token("unavailable");
2246 encoder.token(source_reason_name(*reason));
2247 }
2248 }
2249 let evidence = &clip.evidence;
2250 encoder.f64(evidence.duration_s);
2251 encoder.bool(evidence.speed_mps.is_some());
2252 if let Some(value) = evidence.speed_mps {
2253 encoder.f64(value);
2254 }
2255 encoder.token(availability_name(evidence.speed_mps_availability));
2256 encoder.bool(evidence.loop_endpoint_mode.is_some());
2257 if let Some(value) = evidence.loop_endpoint_mode {
2258 encoder.token(value.as_str());
2259 }
2260 encoder.token(availability_name(evidence.loop_endpoint_mode_availability));
2261 encoder.bool(evidence.frame_grid.is_some());
2262 if let Some(value) = evidence.frame_grid {
2263 encoder.f64(value.fps);
2264 encoder.u64(u64::from(value.frame_intervals));
2265 }
2266 encoder.token(availability_name(evidence.frame_grid_availability));
2267 encoder.bool(evidence.looping.is_some());
2268 if let Some(value) = evidence.looping {
2269 encoder.bool(value);
2270 }
2271 for owner in [
2272 evidence.movement_owner_xz,
2273 evidence.movement_owner_y,
2274 evidence.movement_owner_yaw,
2275 ] {
2276 encoder.bool(owner.is_some());
2277 if let Some(owner) = owner {
2278 encoder.token(owner_name(owner));
2279 }
2280 }
2281}
2282
2283fn encode_payload(encoder: &mut AdviceEncoder, payload: &EngineImportAdvicePayloadV1) {
2284 match payload {
2285 EngineImportAdvicePayloadV1::UnityGeneric { document, clips } => {
2286 encoder.token("unity-generic");
2287 encode_unity_payload(encoder, document, clips);
2288 }
2289 EngineImportAdvicePayloadV1::UnityHumanoid { document, clips } => {
2290 encoder.token("unity-humanoid");
2291 encode_unity_payload(encoder, document, clips);
2292 }
2293 EngineImportAdvicePayloadV1::Unreal => encoder.token("unreal"),
2294 EngineImportAdvicePayloadV1::Godot => encoder.token("godot"),
2295 }
2296}
2297fn encode_unity_payload(
2298 encoder: &mut AdviceEncoder,
2299 document: &UnityDocumentAdviceV1,
2300 clips: &[UnityClipAdviceV1],
2301) {
2302 encoder.bool(document.convert_units);
2303 encoder.bool(document.bake_axis_conversion);
2304 encode_string_option(encoder, document.root_motion_source.as_deref());
2305 encoder.count(clips.len());
2306 for clip in clips {
2307 encoder.u64(clip.normalized_clip_index);
2308 encoder.bool(clip.lock_root_rotation);
2309 encoder.bool(clip.lock_root_height_y);
2310 encoder.bool(clip.lock_root_position_xz);
2311 }
2312}
2313
2314fn refusal_reason_name(reason: EngineImportAdviceRefusalReasonV1) -> &'static str {
2315 match reason {
2316 EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => "profile_settings_unmodeled",
2317 EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
2318 "raw_clip_inventory_incomplete"
2319 }
2320 EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable => "clip_identity_unavailable",
2321 EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch => "clip_identity_mismatch",
2322 EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => "measurement_unavailable",
2323 }
2324}
2325fn source_reason_name(reason: EngineImportAdviceSourceUnavailableReasonV1) -> &'static str {
2326 match reason {
2327 EngineImportAdviceSourceUnavailableReasonV1::Malformed => "malformed",
2328 EngineImportAdviceSourceUnavailableReasonV1::Discarded => "discarded",
2329 EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway => "normalized_away",
2330 EngineImportAdviceSourceUnavailableReasonV1::BakedAway => "baked_away",
2331 EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported => "loader_unsupported",
2332 EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded => {
2333 "projection_budget_exceeded"
2334 }
2335 EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable => "parser_unavailable",
2336 }
2337}
2338fn availability_name(value: MeasurementAvailability) -> &'static str {
2339 match value {
2340 MeasurementAvailability::Measured => "measured",
2341 MeasurementAvailability::NotApplicable => "not_applicable",
2342 MeasurementAvailability::Unavailable => "unavailable",
2343 _ => "unknown",
2344 }
2345}
2346fn owner_name(value: EngineImportAdviceMovementOwnerV1) -> &'static str {
2347 match value {
2348 EngineImportAdviceMovementOwnerV1::Gameplay => "gameplay",
2349 EngineImportAdviceMovementOwnerV1::Animation => "animation",
2350 }
2351}
2352
2353#[cfg(test)]
2354mod tests {
2355 use super::*;
2356 use std::io::Cursor;
2357
2358 #[test]
2359 fn staged_reader_accepts_byte_n_and_rejects_n_plus_one_before_json_decode() {
2360 let exact = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null"), 4);
2361 assert!(matches!(
2362 exact,
2363 Err(EngineImportAdviceReadError::InvalidJson { .. })
2364 ));
2365 let over = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null "), 4);
2366 assert!(matches!(
2367 over,
2368 Err(EngineImportAdviceReadError::ReportTooLarge { limit: 4 })
2369 ));
2370 }
2371}