1use crate::{
8 BakeOrExtract, EngineProfile, ResolvedClipSettings, ResolvedProfile, SettingId, SettingValue,
9 project_prediction_provenance_v1,
10};
11use animsmith_core::measure::{
12 ClipMeasurements, FrameGridMeasurement, LoopEndpointMode, MeasurementAvailability,
13};
14use animsmith_core::{
15 Config, InputIdentity, LoadedSource, MetricGrids, MovementOwner, PredictionProvenanceV1,
16 RawSourceSetCoverageStateV1, SourceObservationStateV1, SourceSetCoverageStateV1, SourceTextV1,
17 SourceUnavailableReasonV1, ToolInfo, resolve_configured_roles,
18};
19use serde::de::{Error as _, SeqAccess, Visitor};
20use serde::{Deserialize, Deserializer, Serialize};
21use serde_json::value::RawValue;
22use std::collections::{BTreeMap, BTreeSet};
23use std::fmt;
24use std::io::Read;
25use std::marker::PhantomData;
26
27pub const ENGINE_IMPORT_ADVICE_V1_ID: &str = "urn:animsmith:schema:engine-import-advice:1";
29pub const ENGINE_IMPORT_ADVICE_SCHEMA_VERSION: u32 = 1;
31pub const ENGINE_IMPORT_ADVICE_COMMAND: &str = "generate-import-advice";
33pub const ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
35
36const MAX_CLIPS: usize = animsmith_core::RAW_SOURCE_V1_MAX_CLIPS;
37const MAX_TEXT_BYTES: usize = animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES;
38const IDENTITY_DOMAIN: &str = "urn:animsmith:engine-import-advice-preimage:1";
39const UNITY_GENERIC_FACTS: (&str, u64) = (
40 "97afc05a02f7f9a946c66945cb84669a8a67d4dae7bf642486b94f1de3a17dd4",
41 3097,
42);
43const UNITY_HUMANOID_FACTS: (&str, u64) = (
44 "43f53df9f26ca3a1248972566029609bcd6b63194cbca399789444622680a12a",
45 2847,
46);
47const UNREAL_FACTS: (&str, u64) = (
48 "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759",
49 2169,
50);
51const GODOT_FACTS: (&str, u64) = (
52 "e9c8316d1655c487b60dd35bbfc70289952c5fa12f4718f0be09c7e9a00fbe87",
53 1166,
54);
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(transparent)]
59pub struct EngineImportAdviceIdentityV1(InputIdentity);
60
61impl EngineImportAdviceIdentityV1 {
62 pub const fn input_identity(&self) -> &InputIdentity {
64 &self.0
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum EngineImportAdviceStateV1 {
72 Available,
74 Refused,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum EngineImportAdviceRefusalReasonV1 {
82 ProfileSettingsUnmodeled,
84 RawClipInventoryIncomplete,
86 ClipIdentityUnavailable,
88 ClipIdentityMismatch,
90 MeasurementUnavailable,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum EngineImportAdviceSourceUnavailableReasonV1 {
98 Malformed,
100 Discarded,
102 NormalizedAway,
104 BakedAway,
106 LoaderUnsupported,
108 ProjectionBudgetExceeded,
110 ParserUnavailable,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
117pub enum EngineImportAdviceSourceNameV1 {
118 Observed {
120 value: String,
122 },
123 ProvenAbsent,
125 Unavailable {
127 reason: EngineImportAdviceSourceUnavailableReasonV1,
129 },
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum EngineImportAdviceMovementOwnerV1 {
136 Gameplay,
138 Animation,
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct EngineImportAdviceClipEvidenceV1 {
146 duration_s: f64,
147 #[serde(
148 default,
149 skip_serializing_if = "Option::is_none",
150 deserialize_with = "deserialize_present_non_null"
151 )]
152 speed_mps: Option<f64>,
153 speed_mps_availability: MeasurementAvailability,
154 #[serde(
155 default,
156 skip_serializing_if = "Option::is_none",
157 deserialize_with = "deserialize_present_non_null"
158 )]
159 loop_endpoint_mode: Option<LoopEndpointMode>,
160 loop_endpoint_mode_availability: MeasurementAvailability,
161 #[serde(
162 default,
163 skip_serializing_if = "Option::is_none",
164 deserialize_with = "deserialize_present_non_null"
165 )]
166 frame_grid: Option<FrameGridMeasurement>,
167 frame_grid_availability: MeasurementAvailability,
168 #[serde(
169 rename = "loop",
170 default,
171 skip_serializing_if = "Option::is_none",
172 deserialize_with = "deserialize_present_non_null"
173 )]
174 looping: Option<bool>,
175 #[serde(
176 default,
177 skip_serializing_if = "Option::is_none",
178 deserialize_with = "deserialize_present_non_null"
179 )]
180 movement_owner_xz: Option<EngineImportAdviceMovementOwnerV1>,
181 #[serde(
182 default,
183 skip_serializing_if = "Option::is_none",
184 deserialize_with = "deserialize_present_non_null"
185 )]
186 movement_owner_y: Option<EngineImportAdviceMovementOwnerV1>,
187 #[serde(
188 default,
189 skip_serializing_if = "Option::is_none",
190 deserialize_with = "deserialize_present_non_null"
191 )]
192 movement_owner_yaw: Option<EngineImportAdviceMovementOwnerV1>,
193}
194
195impl EngineImportAdviceClipEvidenceV1 {
196 pub const fn duration_s(&self) -> f64 {
198 self.duration_s
199 }
200 pub const fn speed_mps(&self) -> Option<f64> {
202 self.speed_mps
203 }
204 pub const fn speed_mps_availability(&self) -> MeasurementAvailability {
206 self.speed_mps_availability
207 }
208 pub const fn loop_endpoint_mode(&self) -> Option<LoopEndpointMode> {
210 self.loop_endpoint_mode
211 }
212 pub const fn loop_endpoint_mode_availability(&self) -> MeasurementAvailability {
214 self.loop_endpoint_mode_availability
215 }
216 pub const fn frame_grid(&self) -> Option<FrameGridMeasurement> {
218 self.frame_grid
219 }
220 pub const fn frame_grid_availability(&self) -> MeasurementAvailability {
222 self.frame_grid_availability
223 }
224 pub const fn looping(&self) -> Option<bool> {
226 self.looping
227 }
228 pub const fn movement_owner_xz(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
230 self.movement_owner_xz
231 }
232 pub const fn movement_owner_y(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
234 self.movement_owner_y
235 }
236 pub const fn movement_owner_yaw(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
238 self.movement_owner_yaw
239 }
240
241 fn validate(&self) -> Result<(), EngineImportAdviceError> {
242 if !self.duration_s.is_finite()
243 || self.duration_s < 0.0
244 || self
245 .speed_mps
246 .is_some_and(|value| !value.is_finite() || value < 0.0)
247 {
248 return Err(EngineImportAdviceError::InvalidMeasurement);
249 }
250 validate_optional(self.speed_mps.is_some(), self.speed_mps_availability)?;
251 validate_optional(
252 self.loop_endpoint_mode.is_some(),
253 self.loop_endpoint_mode_availability,
254 )?;
255 let loop_endpoint_applicability_matches = match self.looping {
256 Some(true) => {
257 self.loop_endpoint_mode_availability != MeasurementAvailability::NotApplicable
258 }
259 Some(false) | None => {
260 self.loop_endpoint_mode_availability == MeasurementAvailability::NotApplicable
261 }
262 };
263 if !loop_endpoint_applicability_matches {
264 return Err(EngineImportAdviceError::InvalidMeasurement);
265 }
266 validate_optional(self.frame_grid.is_some(), self.frame_grid_availability)?;
267 if self.frame_grid.is_some_and(|grid| {
268 !grid.fps.is_finite() || grid.fps <= 0.0 || grid.frame_intervals == 0
269 }) {
270 return Err(EngineImportAdviceError::InvalidMeasurement);
271 }
272 Ok(())
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct EngineImportAdviceClipV1 {
280 source_clip_index: u64,
281 normalized_clip_index: u64,
282 normalized_clip_name: String,
283 source_name: EngineImportAdviceSourceNameV1,
284 evidence: EngineImportAdviceClipEvidenceV1,
285}
286
287impl EngineImportAdviceClipV1 {
288 pub const fn source_clip_index(&self) -> u64 {
290 self.source_clip_index
291 }
292 pub const fn normalized_clip_index(&self) -> u64 {
294 self.normalized_clip_index
295 }
296 pub fn normalized_clip_name(&self) -> &str {
298 &self.normalized_clip_name
299 }
300 pub const fn source_name(&self) -> &EngineImportAdviceSourceNameV1 {
302 &self.source_name
303 }
304 pub const fn evidence(&self) -> &EngineImportAdviceClipEvidenceV1 {
306 &self.evidence
307 }
308
309 fn validate(&self, ordinal: usize) -> Result<(), EngineImportAdviceError> {
310 if self.source_clip_index != ordinal as u64
311 || self.normalized_clip_index >= MAX_CLIPS as u64
312 || self.normalized_clip_name.is_empty()
313 || self.normalized_clip_name.len() > MAX_TEXT_BYTES
314 {
315 return Err(EngineImportAdviceError::InvalidClipIdentity);
316 }
317 if let EngineImportAdviceSourceNameV1::Observed { value } = &self.source_name
318 && (value.is_empty() || value.len() > MAX_TEXT_BYTES)
319 {
320 return Err(EngineImportAdviceError::InvalidClipIdentity);
321 }
322 self.evidence.validate()
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct UnityDocumentAdviceV1 {
330 convert_units: bool,
331 bake_axis_conversion: bool,
332 #[serde(
333 default,
334 skip_serializing_if = "Option::is_none",
335 deserialize_with = "deserialize_present_non_null"
336 )]
337 root_motion_source: Option<String>,
338}
339
340impl UnityDocumentAdviceV1 {
341 pub const fn convert_units(&self) -> bool {
343 self.convert_units
344 }
345 pub const fn bake_axis_conversion(&self) -> bool {
347 self.bake_axis_conversion
348 }
349 pub fn root_motion_source(&self) -> Option<&str> {
351 self.root_motion_source.as_deref()
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(deny_unknown_fields)]
358pub struct UnityClipAdviceV1 {
359 normalized_clip_index: u64,
360 lock_root_rotation: bool,
361 lock_root_height_y: bool,
362 lock_root_position_xz: bool,
363}
364
365impl UnityClipAdviceV1 {
366 pub const fn normalized_clip_index(&self) -> u64 {
368 self.normalized_clip_index
369 }
370 pub const fn lock_root_rotation(&self) -> bool {
372 self.lock_root_rotation
373 }
374 pub const fn lock_root_height_y(&self) -> bool {
376 self.lock_root_height_y
377 }
378 pub const fn lock_root_position_xz(&self) -> bool {
380 self.lock_root_position_xz
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(tag = "engine", rename_all = "kebab-case", deny_unknown_fields)]
387pub enum EngineImportAdvicePayloadV1 {
388 UnityGeneric {
390 document: UnityDocumentAdviceV1,
392 #[serde(deserialize_with = "deserialize_unity_clips")]
394 clips: Vec<UnityClipAdviceV1>,
395 },
396 UnityHumanoid {
398 document: UnityDocumentAdviceV1,
400 #[serde(deserialize_with = "deserialize_unity_clips")]
402 clips: Vec<UnityClipAdviceV1>,
403 },
404 Unreal,
406 Godot,
408}
409
410impl EngineImportAdvicePayloadV1 {
411 pub const fn unity_document(&self) -> Option<&UnityDocumentAdviceV1> {
413 match self {
414 Self::UnityGeneric { document, .. } | Self::UnityHumanoid { document, .. } => {
415 Some(document)
416 }
417 Self::Unreal | Self::Godot => None,
418 }
419 }
420 pub fn unity_clips(&self) -> &[UnityClipAdviceV1] {
422 match self {
423 Self::UnityGeneric { clips, .. } | Self::UnityHumanoid { clips, .. } => clips,
424 Self::Unreal | Self::Godot => &[],
425 }
426 }
427}
428
429#[derive(Debug, Clone, Serialize)]
431pub struct EngineImportAdviceV1 {
432 schema_version: u32,
433 schema: &'static str,
434 tool: ToolInfo,
435 command: &'static str,
436 identity: EngineImportAdviceIdentityV1,
437 prediction_provenance: PredictionProvenanceV1,
438 state: EngineImportAdviceStateV1,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
441 clips: Vec<EngineImportAdviceClipV1>,
442 payload: EngineImportAdvicePayloadV1,
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
447#[non_exhaustive]
448pub enum EngineImportAdviceError {
449 #[error("generate import-advice requires an exact Unity, Unreal, or Godot V1 profile")]
451 UnsupportedProfile,
452 #[error("invalid engine-import-advice configuration: {0}")]
454 InvalidConfig(String),
455 #[error("invalid engine-import-advice V1 header")]
457 WrongHeader,
458 #[error("invalid engine-import-advice producer identity")]
460 InvalidTool,
461 #[error("invalid prediction provenance: {0}")]
463 InvalidProvenance(String),
464 #[error("engine-import-advice profile does not match its payload")]
466 ProfileMismatch,
467 #[error("invalid engine-import-advice lifecycle")]
469 InvalidLifecycle,
470 #[error("invalid engine-import-advice clip identity")]
472 InvalidClipIdentity,
473 #[error("invalid engine-import-advice measurement")]
475 InvalidMeasurement,
476 #[error("engine-import-advice Unity settings do not match prediction provenance")]
478 UnitySettingsMismatch,
479 #[error("engine-import-advice has {found} clips; limit is {limit}")]
481 TooManyClips {
482 found: usize,
484 limit: usize,
486 },
487 #[error("engine-import-advice identity mismatch")]
489 IdentityMismatch,
490}
491
492#[derive(Debug, thiserror::Error)]
494#[non_exhaustive]
495pub enum EngineImportAdviceReadError {
496 #[error("cannot read engine-import-advice document: {source}")]
498 Io {
499 #[source]
501 source: std::io::Error,
502 },
503 #[error("engine-import-advice document exceeds {limit} bytes")]
505 ReportTooLarge {
506 limit: u64,
508 },
509 #[error("invalid engine-import-advice JSON: {source}")]
511 InvalidJson {
512 #[source]
514 source: serde_json::Error,
515 },
516}
517
518impl EngineImportAdviceV1 {
519 pub fn supports_profile(profile: &EngineProfile) -> bool {
525 let selection = profile.selection();
526 let facts = profile.facts_identity();
527 [
528 (
529 "unity-generic",
530 1,
531 "6000.3",
532 "fbx-model-importer",
533 UNITY_GENERIC_FACTS,
534 ),
535 (
536 "unity-humanoid",
537 1,
538 "6000.3",
539 "fbx-model-importer",
540 UNITY_HUMANOID_FACTS,
541 ),
542 ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
543 ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS),
544 ]
545 .into_iter()
546 .any(|(family, revision, version, importer, identity)| {
547 selection.family() == family
548 && selection.profile_revision() == revision
549 && selection.engine_version() == version
550 && selection.importer() == importer
551 && facts.sha256() == identity.0
552 && facts.bytes() == identity.1
553 })
554 }
555
556 pub fn from_source(
570 tool: ToolInfo,
571 source: &LoadedSource,
572 profile: &ResolvedProfile,
573 config: &Config,
574 ) -> Result<Self, EngineImportAdviceError> {
575 config
576 .validate()
577 .map_err(|error| EngineImportAdviceError::InvalidConfig(error.to_string()))?;
578 if !Self::supports_profile(profile.profile()) {
579 return Err(EngineImportAdviceError::UnsupportedProfile);
580 }
581 let prediction_provenance = project_prediction_provenance_v1(profile, source)
582 .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
583 let family = profile.profile().selection().family();
584 let (payload, state, refusal_reason, clips) = match family {
585 "unreal" => (
586 EngineImportAdvicePayloadV1::Unreal,
587 EngineImportAdviceStateV1::Refused,
588 Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
589 Vec::new(),
590 ),
591 "godot" => (
592 EngineImportAdvicePayloadV1::Godot,
593 EngineImportAdviceStateV1::Refused,
594 Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
595 Vec::new(),
596 ),
597 "unity-generic" | "unity-humanoid" => {
598 let generic = family == "unity-generic";
599 let document = unity_document(profile, generic)?;
600 let roles = resolve_configured_roles(&source.document().skeleton, &config.rig);
601 let grids = MetricGrids::new(source.document());
602 let measurements =
603 animsmith_core::measure::measure_document(&grids, &roles, config);
604 match build_clip_evidence(source, &measurements, config) {
605 Ok(clips) => match unity_clip_advice(profile, source.document(), &clips) {
606 Ok(unity_clips) => {
607 let payload = if generic {
608 EngineImportAdvicePayloadV1::UnityGeneric {
609 document,
610 clips: unity_clips,
611 }
612 } else {
613 EngineImportAdvicePayloadV1::UnityHumanoid {
614 document,
615 clips: unity_clips,
616 }
617 };
618 (payload, EngineImportAdviceStateV1::Available, None, clips)
619 }
620 Err(EngineImportAdviceError::UnitySettingsMismatch) => {
621 let payload = if generic {
622 EngineImportAdvicePayloadV1::UnityGeneric {
623 document,
624 clips: Vec::new(),
625 }
626 } else {
627 EngineImportAdvicePayloadV1::UnityHumanoid {
628 document,
629 clips: Vec::new(),
630 }
631 };
632 (
633 payload,
634 EngineImportAdviceStateV1::Refused,
635 Some(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch),
636 Vec::new(),
637 )
638 }
639 Err(error) => return Err(error),
640 },
641 Err(reason) => {
642 let payload = if generic {
643 EngineImportAdvicePayloadV1::UnityGeneric {
644 document,
645 clips: Vec::new(),
646 }
647 } else {
648 EngineImportAdvicePayloadV1::UnityHumanoid {
649 document,
650 clips: Vec::new(),
651 }
652 };
653 (
654 payload,
655 EngineImportAdviceStateV1::Refused,
656 Some(reason),
657 Vec::new(),
658 )
659 }
660 }
661 }
662 _ => return Err(EngineImportAdviceError::UnsupportedProfile),
663 };
664 let mut report = Self {
665 schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
666 schema: ENGINE_IMPORT_ADVICE_V1_ID,
667 tool,
668 command: ENGINE_IMPORT_ADVICE_COMMAND,
669 identity: EngineImportAdviceIdentityV1(InputIdentity::from_bytes(&[])),
670 prediction_provenance,
671 state,
672 refusal_reason,
673 clips,
674 payload,
675 };
676 report.validate_without_identity()?;
677 report.identity = EngineImportAdviceIdentityV1(report.computed_identity());
678 Ok(report)
679 }
680
681 pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
683 &self.identity
684 }
685 pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
687 &self.prediction_provenance
688 }
689 pub const fn state(&self) -> EngineImportAdviceStateV1 {
691 self.state
692 }
693 pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
695 self.refusal_reason
696 }
697 pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
699 &self.clips
700 }
701 pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
703 &self.payload
704 }
705
706 fn validate_without_identity(&self) -> Result<(), EngineImportAdviceError> {
707 if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
708 || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
709 || self.command != ENGINE_IMPORT_ADVICE_COMMAND
710 {
711 return Err(EngineImportAdviceError::WrongHeader);
712 }
713 self.prediction_provenance
714 .validate()
715 .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
716 validate_profile(&self.prediction_provenance, &self.payload)?;
717 if self.clips.len() > MAX_CLIPS {
718 return Err(EngineImportAdviceError::TooManyClips {
719 found: self.clips.len(),
720 limit: MAX_CLIPS,
721 });
722 }
723 let mut normalized = BTreeSet::new();
724 for (ordinal, clip) in self.clips.iter().enumerate() {
725 clip.validate(ordinal)?;
726 if !normalized.insert(clip.normalized_clip_index) {
727 return Err(EngineImportAdviceError::InvalidClipIdentity);
728 }
729 }
730 if normalized
731 .iter()
732 .copied()
733 .ne((0..self.clips.len()).map(|index| index as u64))
734 {
735 return Err(EngineImportAdviceError::InvalidClipIdentity);
736 }
737 let raw_clips_complete = self
738 .prediction_provenance
739 .raw_source()
740 .clips_coverage()
741 .state()
742 == RawSourceSetCoverageStateV1::Complete;
743 match (self.state, self.refusal_reason) {
744 (EngineImportAdviceStateV1::Available, None) => {
745 if !raw_clips_complete
746 || matches!(
747 self.payload,
748 EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
749 )
750 || self.payload.unity_clips().len() != self.clips.len()
751 {
752 return Err(EngineImportAdviceError::InvalidLifecycle);
753 }
754 for (normalized_index, advice) in self.payload.unity_clips().iter().enumerate() {
755 if advice.normalized_clip_index != normalized_index as u64 {
756 return Err(EngineImportAdviceError::InvalidClipIdentity);
757 }
758 }
759 validate_unity_settings_against_provenance(
760 &self.prediction_provenance,
761 &self.clips,
762 &self.payload,
763 )?;
764 }
765 (EngineImportAdviceStateV1::Refused, Some(reason)) => {
766 if !self.clips.is_empty() || !self.payload.unity_clips().is_empty() {
767 return Err(EngineImportAdviceError::InvalidLifecycle);
768 }
769 let unmodeled = matches!(
770 self.payload,
771 EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
772 );
773 if (reason == EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled)
774 != unmodeled
775 {
776 return Err(EngineImportAdviceError::InvalidLifecycle);
777 }
778 if !unmodeled {
779 let coverage_matches_reason = match reason {
780 EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
781 !raw_clips_complete
782 }
783 EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable
784 | EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch
785 | EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => {
786 raw_clips_complete
787 }
788 EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => false,
789 };
790 if !coverage_matches_reason {
791 return Err(EngineImportAdviceError::InvalidLifecycle);
792 }
793 validate_unity_document_against_provenance(
794 &self.prediction_provenance,
795 &self.payload,
796 )?;
797 }
798 }
799 _ => return Err(EngineImportAdviceError::InvalidLifecycle),
800 }
801 Ok(())
802 }
803
804 fn computed_identity(&self) -> InputIdentity {
805 let mut encoder = AdviceEncoder::new(IDENTITY_DOMAIN);
806 encode_identity(
807 &mut encoder,
808 self.prediction_provenance.identity().input_identity(),
809 );
810 encoder.token(match self.state {
811 EngineImportAdviceStateV1::Available => "available",
812 EngineImportAdviceStateV1::Refused => "refused",
813 });
814 encoder.bool(self.refusal_reason.is_some());
815 if let Some(reason) = self.refusal_reason {
816 encoder.token(refusal_reason_name(reason));
817 }
818 encoder.count(self.clips.len());
819 for clip in &self.clips {
820 encode_clip(&mut encoder, clip);
821 }
822 encode_payload(&mut encoder, &self.payload);
823 InputIdentity::from_bytes(&encoder.bytes)
824 }
825}
826
827fn build_clip_evidence(
828 source: &LoadedSource,
829 measurements: &BTreeMap<String, ClipMeasurements>,
830 config: &Config,
831) -> Result<Vec<EngineImportAdviceClipV1>, EngineImportAdviceRefusalReasonV1> {
832 let raw = source.source_facts().clips();
833 if raw.coverage().state() != SourceSetCoverageStateV1::Complete {
834 return Err(EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete);
835 }
836 let document = source.document();
837 if raw.rows().len() != document.clips.len() || measurements.len() != document.clips.len() {
838 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
839 }
840 let mut normalized = BTreeSet::new();
841 let mut rows = Vec::with_capacity(raw.rows().len());
842 for (ordinal, raw_clip) in raw.rows().iter().enumerate() {
843 if raw_clip.source_clip_index() != ordinal {
844 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
845 }
846 let normalized_index = match raw_clip.normalized_clip_index().state() {
847 SourceObservationStateV1::Observed(value) => *value,
848 SourceObservationStateV1::ProvenAbsent | SourceObservationStateV1::Unavailable(_) => {
849 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable);
850 }
851 };
852 if normalized_index >= document.clips.len() || !normalized.insert(normalized_index) {
853 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
854 }
855 let clip = &document.clips[normalized_index];
856 let Some(measurement) = measurements.get(&clip.name) else {
857 return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
858 };
859 let expectations = config.expectations_for(&clip.name);
860 let evidence = EngineImportAdviceClipEvidenceV1 {
861 duration_s: measurement.duration_s,
862 speed_mps: measurement.speed_mps,
863 speed_mps_availability: measurement.speed_mps_availability,
864 loop_endpoint_mode: measurement.loop_endpoint_mode,
865 loop_endpoint_mode_availability: measurement.loop_endpoint_mode_availability,
866 frame_grid: measurement.frame_grid,
867 frame_grid_availability: measurement.frame_grid_availability,
868 looping: expectations.looping,
869 movement_owner_xz: expectations.movement_owner_xz.map(movement_owner),
870 movement_owner_y: expectations.movement_owner_y.map(movement_owner),
871 movement_owner_yaw: expectations.movement_owner_yaw.map(movement_owner),
872 };
873 if evidence.validate().is_err() {
874 return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
875 }
876 rows.push(EngineImportAdviceClipV1 {
877 source_clip_index: ordinal as u64,
878 normalized_clip_index: normalized_index as u64,
879 normalized_clip_name: clip.name.clone(),
880 source_name: source_name(raw_clip.source_name().state()),
881 evidence,
882 });
883 }
884 if normalized.len() != document.clips.len() {
885 return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
886 }
887 Ok(rows)
888}
889
890fn unity_document(
891 profile: &ResolvedProfile,
892 generic: bool,
893) -> Result<UnityDocumentAdviceV1, EngineImportAdviceError> {
894 let convert_units = bool_setting(profile.document_settings().get(&SettingId::ConvertUnits))?;
895 let bake_axis_conversion = bool_setting(
896 profile
897 .document_settings()
898 .get(&SettingId::BakeAxisConversion),
899 )?;
900 let root_motion_source = if generic {
901 match profile
902 .document_settings()
903 .get(&SettingId::RootMotionSource)
904 {
905 Some(SettingValue::SourceTransformPath(value)) => Some(value.clone()),
906 _ => return Err(EngineImportAdviceError::UnitySettingsMismatch),
907 }
908 } else {
909 None
910 };
911 Ok(UnityDocumentAdviceV1 {
912 convert_units,
913 bake_axis_conversion,
914 root_motion_source,
915 })
916}
917
918fn unity_clip_advice(
919 profile: &ResolvedProfile,
920 document: &animsmith_core::Document,
921 clips: &[EngineImportAdviceClipV1],
922) -> Result<Vec<UnityClipAdviceV1>, EngineImportAdviceError> {
923 let settings = settings_by_document_index(profile.clip_settings(), document)?;
924 let mut advice = clips
925 .iter()
926 .map(|clip| {
927 let row = settings
928 .get(clip.normalized_clip_index as usize)
929 .ok_or(EngineImportAdviceError::UnitySettingsMismatch)?;
930 Ok(UnityClipAdviceV1 {
931 normalized_clip_index: clip.normalized_clip_index,
932 lock_root_rotation: bake_setting(row.settings().get(&SettingId::RootRotation))?,
933 lock_root_height_y: bake_setting(row.settings().get(&SettingId::RootPositionY))?,
934 lock_root_position_xz: bake_setting(
935 row.settings().get(&SettingId::RootPositionXz),
936 )?,
937 })
938 })
939 .collect::<Result<Vec<_>, _>>()?;
940 advice.sort_by_key(|clip| clip.normalized_clip_index);
941 Ok(advice)
942}
943
944fn settings_by_document_index<'a>(
945 settings: &'a [ResolvedClipSettings],
946 document: &animsmith_core::Document,
947) -> Result<Vec<&'a ResolvedClipSettings>, EngineImportAdviceError> {
948 if settings.len() != document.clips.len() {
949 return Err(EngineImportAdviceError::UnitySettingsMismatch);
950 }
951 let mut used = vec![false; settings.len()];
952 document
953 .clips
954 .iter()
955 .map(|clip| {
956 let Some((index, row)) = settings
957 .iter()
958 .enumerate()
959 .find(|(index, row)| !used[*index] && row.clip_name() == clip.name)
960 else {
961 return Err(EngineImportAdviceError::UnitySettingsMismatch);
962 };
963 used[index] = true;
964 Ok(row)
965 })
966 .collect()
967}
968
969fn bool_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
970 match value {
971 Some(SettingValue::Boolean(value)) => Ok(*value),
972 _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
973 }
974}
975
976fn bake_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
977 match value {
978 Some(SettingValue::BakeOrExtract(BakeOrExtract::Bake)) => Ok(true),
979 Some(SettingValue::BakeOrExtract(BakeOrExtract::Extract)) => Ok(false),
980 _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
981 }
982}
983
984fn movement_owner(value: MovementOwner) -> EngineImportAdviceMovementOwnerV1 {
985 match value {
986 MovementOwner::Gameplay => EngineImportAdviceMovementOwnerV1::Gameplay,
987 MovementOwner::Animation => EngineImportAdviceMovementOwnerV1::Animation,
988 }
989}
990
991fn source_name(state: &SourceObservationStateV1<SourceTextV1>) -> EngineImportAdviceSourceNameV1 {
992 match state {
993 SourceObservationStateV1::Observed(value) => EngineImportAdviceSourceNameV1::Observed {
994 value: value.as_str().to_owned(),
995 },
996 SourceObservationStateV1::ProvenAbsent => EngineImportAdviceSourceNameV1::ProvenAbsent,
997 SourceObservationStateV1::Unavailable(reason) => {
998 EngineImportAdviceSourceNameV1::Unavailable {
999 reason: source_reason(*reason),
1000 }
1001 }
1002 }
1003}
1004
1005fn source_reason(reason: SourceUnavailableReasonV1) -> EngineImportAdviceSourceUnavailableReasonV1 {
1006 match reason {
1007 SourceUnavailableReasonV1::Malformed => {
1008 EngineImportAdviceSourceUnavailableReasonV1::Malformed
1009 }
1010 SourceUnavailableReasonV1::Discarded => {
1011 EngineImportAdviceSourceUnavailableReasonV1::Discarded
1012 }
1013 SourceUnavailableReasonV1::NormalizedAway => {
1014 EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway
1015 }
1016 SourceUnavailableReasonV1::BakedAway => {
1017 EngineImportAdviceSourceUnavailableReasonV1::BakedAway
1018 }
1019 SourceUnavailableReasonV1::LoaderUnsupported => {
1020 EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported
1021 }
1022 SourceUnavailableReasonV1::ProjectionBudgetExceeded => {
1023 EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded
1024 }
1025 SourceUnavailableReasonV1::ParserUnavailable => {
1026 EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable
1027 }
1028 }
1029}
1030
1031fn validate_optional(
1032 present: bool,
1033 availability: MeasurementAvailability,
1034) -> Result<(), EngineImportAdviceError> {
1035 if matches!(availability, MeasurementAvailability::Measured) != present {
1036 return Err(EngineImportAdviceError::InvalidMeasurement);
1037 }
1038 Ok(())
1039}
1040
1041fn validate_unity_document_against_provenance(
1042 provenance: &PredictionProvenanceV1,
1043 payload: &EngineImportAdvicePayloadV1,
1044) -> Result<(), EngineImportAdviceError> {
1045 use animsmith_core::{EngineSettingIdV1 as Id, EngineSettingValueV1 as Value};
1046 let Some(document) = payload.unity_document() else {
1047 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1048 };
1049 let settings = provenance.settings();
1050 if settings.document_setting(Id::ConvertUnits) != Some(&Value::Boolean(document.convert_units))
1051 || settings.document_setting(Id::BakeAxisConversion)
1052 != Some(&Value::Boolean(document.bake_axis_conversion))
1053 {
1054 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1055 }
1056 let generic = matches!(payload, EngineImportAdvicePayloadV1::UnityGeneric { .. });
1057 match (
1058 generic,
1059 document.root_motion_source.as_ref(),
1060 settings.document_setting(Id::RootMotionSource),
1061 ) {
1062 (true, Some(expected), Some(Value::SourceTransformPath(actual))) if expected == actual => {
1063 Ok(())
1064 }
1065 (false, None, None) => Ok(()),
1066 _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
1067 }
1068}
1069
1070fn validate_unity_settings_against_provenance(
1071 provenance: &PredictionProvenanceV1,
1072 clips: &[EngineImportAdviceClipV1],
1073 payload: &EngineImportAdvicePayloadV1,
1074) -> Result<(), EngineImportAdviceError> {
1075 use animsmith_core::{
1076 EngineBakeOrExtractV1 as Bake, EngineSettingIdV1 as Id, EngineSettingValueV1 as Value,
1077 };
1078 validate_unity_document_against_provenance(provenance, payload)?;
1079 let settings = provenance.settings();
1080 if settings.clips().len() != clips.len() {
1081 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1082 }
1083 let mut used = vec![false; settings.clips().len()];
1084 for advice in payload.unity_clips() {
1085 let Some(clip) = clips
1086 .iter()
1087 .find(|clip| clip.normalized_clip_index == advice.normalized_clip_index)
1088 else {
1089 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1090 };
1091 let Some((index, row)) = settings
1092 .clips()
1093 .iter()
1094 .enumerate()
1095 .find(|(index, row)| !used[*index] && row.clip_name() == clip.normalized_clip_name)
1096 else {
1097 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1098 };
1099 used[index] = true;
1100 for (id, baked) in [
1101 (Id::RootRotation, advice.lock_root_rotation),
1102 (Id::RootPositionY, advice.lock_root_height_y),
1103 (Id::RootPositionXz, advice.lock_root_position_xz),
1104 ] {
1105 let expected = Value::BakeOrExtract(if baked { Bake::Bake } else { Bake::Extract });
1106 if row.setting(id) != Some(&expected) {
1107 return Err(EngineImportAdviceError::UnitySettingsMismatch);
1108 }
1109 }
1110 }
1111 Ok(())
1112}
1113
1114fn validate_profile(
1115 provenance: &PredictionProvenanceV1,
1116 payload: &EngineImportAdvicePayloadV1,
1117) -> Result<(), EngineImportAdviceError> {
1118 let selection = provenance.profile().selection();
1119 let facts = provenance.profile().facts_identity();
1120 let (family, revision, version, importer, identity) = match payload {
1121 EngineImportAdvicePayloadV1::UnityGeneric { .. } => (
1122 "unity-generic",
1123 1,
1124 "6000.3",
1125 "fbx-model-importer",
1126 UNITY_GENERIC_FACTS,
1127 ),
1128 EngineImportAdvicePayloadV1::UnityHumanoid { .. } => (
1129 "unity-humanoid",
1130 1,
1131 "6000.3",
1132 "fbx-model-importer",
1133 UNITY_HUMANOID_FACTS,
1134 ),
1135 EngineImportAdvicePayloadV1::Unreal => ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
1136 EngineImportAdvicePayloadV1::Godot => {
1137 ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS)
1138 }
1139 };
1140 if selection.family() != family
1141 || selection.profile_revision() != revision
1142 || selection.engine_version() != version
1143 || selection.importer() != importer
1144 || facts.sha256() != identity.0
1145 || facts.bytes() != identity.1
1146 {
1147 return Err(EngineImportAdviceError::ProfileMismatch);
1148 }
1149 Ok(())
1150}
1151
1152#[derive(Debug, Deserialize)]
1153#[serde(deny_unknown_fields)]
1154struct ToolSourceInputV1 {
1155 #[serde(default, deserialize_with = "deserialize_required_nullable")]
1156 revision: RequiredNullable<String>,
1157 #[serde(default, deserialize_with = "deserialize_required_nullable")]
1158 dirty: RequiredNullable<bool>,
1159}
1160
1161#[derive(Debug, Default)]
1162enum RequiredNullable<T> {
1163 #[default]
1164 Missing,
1165 Present(Option<T>),
1166}
1167
1168fn deserialize_required_nullable<'de, D, T>(
1169 deserializer: D,
1170) -> Result<RequiredNullable<T>, D::Error>
1171where
1172 D: Deserializer<'de>,
1173 T: Deserialize<'de>,
1174{
1175 Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
1176}
1177
1178fn deserialize_present_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1179where
1180 D: Deserializer<'de>,
1181 T: Deserialize<'de>,
1182{
1183 T::deserialize(deserializer).map(Some)
1184}
1185
1186#[derive(Debug, Deserialize)]
1187#[serde(deny_unknown_fields)]
1188struct ToolInputV1 {
1189 name: String,
1190 version: String,
1191 source: ToolSourceInputV1,
1192}
1193
1194#[derive(Debug, Deserialize)]
1196#[serde(deny_unknown_fields)]
1197pub struct EngineImportAdviceInput {
1198 schema_version: u32,
1199 schema: String,
1200 tool: ToolInputV1,
1201 command: String,
1202 identity: EngineImportAdviceIdentityV1,
1203 prediction_provenance: Box<RawValue>,
1204 state: EngineImportAdviceStateV1,
1205 #[serde(default, deserialize_with = "deserialize_present_non_null")]
1206 refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1207 #[serde(deserialize_with = "deserialize_clips")]
1208 clips: Vec<EngineImportAdviceClipV1>,
1209 payload: EngineImportAdvicePayloadV1,
1210}
1211
1212impl EngineImportAdviceInput {
1213 pub fn read_from(reader: impl Read) -> Result<Self, EngineImportAdviceReadError> {
1219 Self::read_from_with_limit(reader, ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES)
1220 }
1221
1222 fn read_from_with_limit(
1223 reader: impl Read,
1224 limit: u64,
1225 ) -> Result<Self, EngineImportAdviceReadError> {
1226 let mut bytes = Vec::new();
1227 reader
1228 .take(limit.saturating_add(1))
1229 .read_to_end(&mut bytes)
1230 .map_err(|source| EngineImportAdviceReadError::Io { source })?;
1231 if bytes.len() as u64 > limit {
1232 return Err(EngineImportAdviceReadError::ReportTooLarge { limit });
1233 }
1234 serde_json::from_slice(&bytes)
1235 .map_err(|source| EngineImportAdviceReadError::InvalidJson { source })
1236 }
1237
1238 pub fn into_report(self) -> Result<EngineImportAdviceReadbackV1, EngineImportAdviceError> {
1244 if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
1245 || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
1246 || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1247 {
1248 return Err(EngineImportAdviceError::WrongHeader);
1249 }
1250 validate_tool(&self.tool)?;
1251 let prediction_provenance: PredictionProvenanceV1 =
1252 serde_json::from_str(self.prediction_provenance.get())
1253 .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
1254 let report = EngineImportAdviceReadbackV1 {
1255 tool: EngineImportAdviceToolReadbackV1 {
1256 name: self.tool.name,
1257 version: self.tool.version,
1258 revision: required_nullable_value(self.tool.source.revision),
1259 dirty: required_nullable_value(self.tool.source.dirty),
1260 },
1261 identity: self.identity,
1262 prediction_provenance,
1263 state: self.state,
1264 refusal_reason: self.refusal_reason,
1265 clips: self.clips,
1266 payload: self.payload,
1267 };
1268 report.validate()?;
1269 Ok(report)
1270 }
1271}
1272
1273#[derive(Debug, Clone)]
1275pub struct EngineImportAdviceReadbackV1 {
1276 tool: EngineImportAdviceToolReadbackV1,
1277 identity: EngineImportAdviceIdentityV1,
1278 prediction_provenance: PredictionProvenanceV1,
1279 state: EngineImportAdviceStateV1,
1280 refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1281 clips: Vec<EngineImportAdviceClipV1>,
1282 payload: EngineImportAdvicePayloadV1,
1283}
1284
1285impl EngineImportAdviceReadbackV1 {
1286 pub const fn tool(&self) -> &EngineImportAdviceToolReadbackV1 {
1288 &self.tool
1289 }
1290 pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
1292 &self.identity
1293 }
1294 pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
1296 &self.prediction_provenance
1297 }
1298 pub const fn state(&self) -> EngineImportAdviceStateV1 {
1300 self.state
1301 }
1302 pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
1304 self.refusal_reason
1305 }
1306 pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
1308 &self.clips
1309 }
1310 pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
1312 &self.payload
1313 }
1314
1315 fn validate(&self) -> Result<(), EngineImportAdviceError> {
1316 let surrogate = EngineImportAdviceV1 {
1317 schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
1318 schema: ENGINE_IMPORT_ADVICE_V1_ID,
1319 tool: ToolInfo::animsmith(animsmith_core::ToolSource::new(None, None)),
1320 command: ENGINE_IMPORT_ADVICE_COMMAND,
1321 identity: self.identity.clone(),
1322 prediction_provenance: self.prediction_provenance.clone(),
1323 state: self.state,
1324 refusal_reason: self.refusal_reason,
1325 clips: self.clips.clone(),
1326 payload: self.payload.clone(),
1327 };
1328 surrogate.validate_without_identity()?;
1329 if surrogate.computed_identity() != self.identity.0 {
1330 return Err(EngineImportAdviceError::IdentityMismatch);
1331 }
1332 Ok(())
1333 }
1334}
1335
1336#[derive(Debug, Clone, PartialEq, Eq)]
1338pub struct EngineImportAdviceToolReadbackV1 {
1339 name: String,
1340 version: String,
1341 revision: Option<String>,
1342 dirty: Option<bool>,
1343}
1344
1345impl EngineImportAdviceToolReadbackV1 {
1346 pub fn name(&self) -> &str {
1348 &self.name
1349 }
1350 pub fn version(&self) -> &str {
1352 &self.version
1353 }
1354 pub fn revision(&self) -> Option<&str> {
1356 self.revision.as_deref()
1357 }
1358 pub const fn dirty(&self) -> Option<bool> {
1360 self.dirty
1361 }
1362}
1363
1364struct BoundedSequenceVisitor<T> {
1365 element: PhantomData<fn() -> T>,
1366}
1367
1368impl<'de, T> Visitor<'de> for BoundedSequenceVisitor<T>
1369where
1370 T: Deserialize<'de>,
1371{
1372 type Value = Vec<T>;
1373 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1374 write!(formatter, "a sequence with at most {MAX_CLIPS} clip rows")
1375 }
1376 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1377 where
1378 A: SeqAccess<'de>,
1379 {
1380 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX_CLIPS));
1381 while values.len() < MAX_CLIPS {
1382 let Some(value) = sequence.next_element()? else {
1383 return Ok(values);
1384 };
1385 values.push(value);
1386 }
1387 if sequence.next_element::<serde::de::IgnoredAny>()?.is_some() {
1388 return Err(A::Error::custom(EngineImportAdviceError::TooManyClips {
1389 found: MAX_CLIPS + 1,
1390 limit: MAX_CLIPS,
1391 }));
1392 }
1393 Ok(values)
1394 }
1395}
1396
1397fn deserialize_bounded_clips<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
1398where
1399 D: Deserializer<'de>,
1400 T: Deserialize<'de>,
1401{
1402 deserializer.deserialize_seq(BoundedSequenceVisitor {
1403 element: PhantomData,
1404 })
1405}
1406fn deserialize_clips<'de, D>(deserializer: D) -> Result<Vec<EngineImportAdviceClipV1>, D::Error>
1407where
1408 D: Deserializer<'de>,
1409{
1410 deserialize_bounded_clips(deserializer)
1411}
1412fn deserialize_unity_clips<'de, D>(deserializer: D) -> Result<Vec<UnityClipAdviceV1>, D::Error>
1413where
1414 D: Deserializer<'de>,
1415{
1416 deserialize_bounded_clips(deserializer)
1417}
1418
1419fn validate_tool(tool: &ToolInputV1) -> Result<(), EngineImportAdviceError> {
1420 if tool.name != "animsmith"
1421 || tool.name.len() > MAX_TEXT_BYTES
1422 || tool.version.len() > MAX_TEXT_BYTES
1423 || !is_schema_semver(&tool.version)
1424 || matches!(tool.source.revision, RequiredNullable::Missing)
1425 || matches!(tool.source.dirty, RequiredNullable::Missing)
1426 || matches!(
1427 &tool.source.revision,
1428 RequiredNullable::Present(Some(revision))
1429 if revision.len() != 40
1430 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit())
1431 )
1432 {
1433 return Err(EngineImportAdviceError::InvalidTool);
1434 }
1435 Ok(())
1436}
1437
1438fn required_nullable_value<T>(value: RequiredNullable<T>) -> Option<T> {
1439 match value {
1440 RequiredNullable::Present(value) => value,
1441 RequiredNullable::Missing => unreachable!("validated required nullable field"),
1442 }
1443}
1444
1445fn is_schema_semver(value: &str) -> bool {
1446 let without_build = match value.split_once('+') {
1447 Some((core, build))
1448 if !build.is_empty()
1449 && !build.contains('+')
1450 && build
1451 .bytes()
1452 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
1453 {
1454 core
1455 }
1456 Some(_) => return false,
1457 None => value,
1458 };
1459 let core = match without_build.split_once('-') {
1460 Some((core, pre))
1461 if !pre.is_empty()
1462 && pre
1463 .bytes()
1464 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
1465 {
1466 core
1467 }
1468 Some(_) => return false,
1469 None => without_build,
1470 };
1471 let parts = core.split('.').collect::<Vec<_>>();
1472 parts.len() == 3
1473 && parts.iter().all(|part| {
1474 !part.is_empty()
1475 && part.bytes().all(|byte| byte.is_ascii_digit())
1476 && (*part == "0" || !part.starts_with('0'))
1477 })
1478}
1479
1480struct AdviceEncoder {
1481 bytes: Vec<u8>,
1482}
1483impl AdviceEncoder {
1484 fn new(domain: &str) -> Self {
1485 let mut value = Self { bytes: Vec::new() };
1486 value.token(domain);
1487 value
1488 }
1489 fn token(&mut self, value: &str) {
1490 self.bytes
1491 .extend_from_slice(&(value.len() as u64).to_le_bytes());
1492 self.bytes.extend_from_slice(value.as_bytes());
1493 }
1494 fn count(&mut self, value: usize) {
1495 self.u64(value as u64);
1496 }
1497 fn u64(&mut self, value: u64) {
1498 self.bytes.extend_from_slice(&value.to_le_bytes());
1499 }
1500 fn bool(&mut self, value: bool) {
1501 self.bytes.push(u8::from(value));
1502 }
1503 fn f64(&mut self, value: f64) {
1504 self.bytes.extend_from_slice(&value.to_bits().to_le_bytes());
1505 }
1506}
1507
1508fn encode_identity(encoder: &mut AdviceEncoder, value: &InputIdentity) {
1509 encoder.token(value.sha256());
1510 encoder.u64(value.bytes());
1511}
1512fn encode_string_option(encoder: &mut AdviceEncoder, value: Option<&str>) {
1513 encoder.bool(value.is_some());
1514 if let Some(value) = value {
1515 encoder.token(value);
1516 }
1517}
1518fn encode_clip(encoder: &mut AdviceEncoder, clip: &EngineImportAdviceClipV1) {
1519 encoder.u64(clip.source_clip_index);
1520 encoder.u64(clip.normalized_clip_index);
1521 encoder.token(&clip.normalized_clip_name);
1522 match &clip.source_name {
1523 EngineImportAdviceSourceNameV1::Observed { value } => {
1524 encoder.token("observed");
1525 encoder.token(value);
1526 }
1527 EngineImportAdviceSourceNameV1::ProvenAbsent => encoder.token("proven_absent"),
1528 EngineImportAdviceSourceNameV1::Unavailable { reason } => {
1529 encoder.token("unavailable");
1530 encoder.token(source_reason_name(*reason));
1531 }
1532 }
1533 let evidence = &clip.evidence;
1534 encoder.f64(evidence.duration_s);
1535 encoder.bool(evidence.speed_mps.is_some());
1536 if let Some(value) = evidence.speed_mps {
1537 encoder.f64(value);
1538 }
1539 encoder.token(availability_name(evidence.speed_mps_availability));
1540 encoder.bool(evidence.loop_endpoint_mode.is_some());
1541 if let Some(value) = evidence.loop_endpoint_mode {
1542 encoder.token(value.as_str());
1543 }
1544 encoder.token(availability_name(evidence.loop_endpoint_mode_availability));
1545 encoder.bool(evidence.frame_grid.is_some());
1546 if let Some(value) = evidence.frame_grid {
1547 encoder.f64(value.fps);
1548 encoder.u64(u64::from(value.frame_intervals));
1549 }
1550 encoder.token(availability_name(evidence.frame_grid_availability));
1551 encoder.bool(evidence.looping.is_some());
1552 if let Some(value) = evidence.looping {
1553 encoder.bool(value);
1554 }
1555 for owner in [
1556 evidence.movement_owner_xz,
1557 evidence.movement_owner_y,
1558 evidence.movement_owner_yaw,
1559 ] {
1560 encoder.bool(owner.is_some());
1561 if let Some(owner) = owner {
1562 encoder.token(owner_name(owner));
1563 }
1564 }
1565}
1566
1567fn encode_payload(encoder: &mut AdviceEncoder, payload: &EngineImportAdvicePayloadV1) {
1568 match payload {
1569 EngineImportAdvicePayloadV1::UnityGeneric { document, clips } => {
1570 encoder.token("unity-generic");
1571 encode_unity_payload(encoder, document, clips);
1572 }
1573 EngineImportAdvicePayloadV1::UnityHumanoid { document, clips } => {
1574 encoder.token("unity-humanoid");
1575 encode_unity_payload(encoder, document, clips);
1576 }
1577 EngineImportAdvicePayloadV1::Unreal => encoder.token("unreal"),
1578 EngineImportAdvicePayloadV1::Godot => encoder.token("godot"),
1579 }
1580}
1581fn encode_unity_payload(
1582 encoder: &mut AdviceEncoder,
1583 document: &UnityDocumentAdviceV1,
1584 clips: &[UnityClipAdviceV1],
1585) {
1586 encoder.bool(document.convert_units);
1587 encoder.bool(document.bake_axis_conversion);
1588 encode_string_option(encoder, document.root_motion_source.as_deref());
1589 encoder.count(clips.len());
1590 for clip in clips {
1591 encoder.u64(clip.normalized_clip_index);
1592 encoder.bool(clip.lock_root_rotation);
1593 encoder.bool(clip.lock_root_height_y);
1594 encoder.bool(clip.lock_root_position_xz);
1595 }
1596}
1597
1598fn refusal_reason_name(reason: EngineImportAdviceRefusalReasonV1) -> &'static str {
1599 match reason {
1600 EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => "profile_settings_unmodeled",
1601 EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
1602 "raw_clip_inventory_incomplete"
1603 }
1604 EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable => "clip_identity_unavailable",
1605 EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch => "clip_identity_mismatch",
1606 EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => "measurement_unavailable",
1607 }
1608}
1609fn source_reason_name(reason: EngineImportAdviceSourceUnavailableReasonV1) -> &'static str {
1610 match reason {
1611 EngineImportAdviceSourceUnavailableReasonV1::Malformed => "malformed",
1612 EngineImportAdviceSourceUnavailableReasonV1::Discarded => "discarded",
1613 EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway => "normalized_away",
1614 EngineImportAdviceSourceUnavailableReasonV1::BakedAway => "baked_away",
1615 EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported => "loader_unsupported",
1616 EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded => {
1617 "projection_budget_exceeded"
1618 }
1619 EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable => "parser_unavailable",
1620 }
1621}
1622fn availability_name(value: MeasurementAvailability) -> &'static str {
1623 match value {
1624 MeasurementAvailability::Measured => "measured",
1625 MeasurementAvailability::NotApplicable => "not_applicable",
1626 MeasurementAvailability::Unavailable => "unavailable",
1627 _ => "unknown",
1628 }
1629}
1630fn owner_name(value: EngineImportAdviceMovementOwnerV1) -> &'static str {
1631 match value {
1632 EngineImportAdviceMovementOwnerV1::Gameplay => "gameplay",
1633 EngineImportAdviceMovementOwnerV1::Animation => "animation",
1634 }
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639 use super::*;
1640 use std::io::Cursor;
1641
1642 #[test]
1643 fn staged_reader_accepts_byte_n_and_rejects_n_plus_one_before_json_decode() {
1644 let exact = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null"), 4);
1645 assert!(matches!(
1646 exact,
1647 Err(EngineImportAdviceReadError::InvalidJson { .. })
1648 ));
1649 let over = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null "), 4);
1650 assert!(matches!(
1651 over,
1652 Err(EngineImportAdviceReadError::ReportTooLarge { limit: 4 })
1653 ));
1654 }
1655}