1use animsmith_core::evaluation::{
8 Applicability, CheckEvaluation, ConfigurationState, EvaluationScope, EvaluationState,
9 SelectionState,
10};
11use animsmith_core::{
12 DependencyClosureV1, EngineAnimationAddressabilityV1, EngineFactIdV1, EngineFactStateV1,
13 EngineFactValueV1, EnginePredictionFacetStateV1, EnginePredictionV1, InputIdentity,
14 LoadedSource, PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE, PREDICTION_V1_MAX_FACETS_PER_FILE,
15 PredictionBasisReferenceV1, PredictionContractError, PredictionProvenanceV1,
16 PredictionScalarV1, PredictionUnavailableReasonV1, RawSourceDomainV1, RawSourceKeyV1,
17 RawSourceSetCoverageStateV1, RawSourceUnavailableReasonV1, SourceChannelPropertyV1,
18 SourceFormatV1, SourceObservationStateV1, SourceSetCoverageStateV1, SourceTargetKindV1,
19 SourceUnavailableReasonV1, ToolInfo,
20};
21use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor};
22use serde::{Deserialize, Deserializer, Serialize};
23use serde_json::value::RawValue;
24use std::convert::Infallible;
25use std::fmt;
26use std::io::Read;
27use std::marker::PhantomData;
28
29pub const GLTF_ANIMATION_ADDRESSABILITY_SCHEMA_VERSION: u32 = 1;
31pub const GLTF_ANIMATION_ADDRESSABILITY_V1_ID: &str =
33 "urn:animsmith:schema:gltf-animation-addressability:1";
34pub const GLTF_ANIMATION_ADDRESSABILITY_COMMAND: &str = "generate-addressability";
36pub const GLTF_ANIMATION_ADDRESSABILITY_V1_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
38
39const INVENTORY_IDENTITY_DOMAIN: &str = "animsmith-gltf-animation-addressability-inventory-v1";
40const ENGINE_ADDRESSABILITY_CHECK_ID: &str = "engine-addressability";
41const BEVY_FAMILY: &str = "bevy";
42const BEVY_PROFILE_REVISION: u32 = 1;
43const BEVY_ENGINE_VERSION: &str = "0.19.0";
44const BEVY_IMPORTER: &str = "gltf-asset-loader";
45const BEVY_ANIMATION_LABEL_SOURCE: &str = "bevy-gltf-asset-label-0.19.0";
46const BEVY_PROFILE_FACTS_SHA256: &str =
47 "873b98e896f05de73d5ea30560a4555c1f93650beeec9ed929e30dbcf7ce8c1e";
48const BEVY_PROFILE_FACTS_CANONICAL_BYTES: u64 = 1_642;
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(transparent)]
53pub struct GltfAnimationAddressabilityIdentityV1(InputIdentity);
54
55impl GltfAnimationAddressabilityIdentityV1 {
56 pub const fn input_identity(&self) -> &InputIdentity {
58 &self.0
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum GltfAnimationUnavailableReasonV1 {
66 Malformed,
68 Discarded,
70 NormalizedAway,
72 BakedAway,
74 LoaderUnsupported,
76 ProjectionBudgetExceeded,
78 ParserUnavailable,
80}
81
82impl From<SourceUnavailableReasonV1> for GltfAnimationUnavailableReasonV1 {
83 fn from(value: SourceUnavailableReasonV1) -> Self {
84 match value {
85 SourceUnavailableReasonV1::Malformed => Self::Malformed,
86 SourceUnavailableReasonV1::Discarded => Self::Discarded,
87 SourceUnavailableReasonV1::NormalizedAway => Self::NormalizedAway,
88 SourceUnavailableReasonV1::BakedAway => Self::BakedAway,
89 SourceUnavailableReasonV1::LoaderUnsupported => Self::LoaderUnsupported,
90 SourceUnavailableReasonV1::ProjectionBudgetExceeded => Self::ProjectionBudgetExceeded,
91 SourceUnavailableReasonV1::ParserUnavailable => Self::ParserUnavailable,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum GltfAnimationCoverageStateV1 {
100 Complete,
102 Partial,
104 Unavailable,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(deny_unknown_fields)]
111pub struct GltfAnimationCoverageV1 {
112 state: GltfAnimationCoverageStateV1,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 reason: Option<GltfAnimationUnavailableReasonV1>,
115}
116
117#[derive(Deserialize)]
118#[serde(deny_unknown_fields)]
119struct GltfAnimationCoverageWireV1 {
120 state: GltfAnimationCoverageStateV1,
121 #[serde(default, deserialize_with = "deserialize_required_nullable")]
122 reason: RequiredNullable<GltfAnimationUnavailableReasonV1>,
123}
124
125impl<'de> Deserialize<'de> for GltfAnimationCoverageV1 {
126 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127 where
128 D: Deserializer<'de>,
129 {
130 let wire = GltfAnimationCoverageWireV1::deserialize(deserializer)?;
131 let reason = match (wire.state, wire.reason) {
132 (GltfAnimationCoverageStateV1::Complete, RequiredNullable::Missing) => None,
133 (
134 GltfAnimationCoverageStateV1::Partial | GltfAnimationCoverageStateV1::Unavailable,
135 RequiredNullable::Present(Some(reason)),
136 ) => Some(reason),
137 _ => {
138 return Err(D::Error::custom(
139 GltfAnimationAddressabilityError::InvalidCoverage,
140 ));
141 }
142 };
143 let value = Self {
144 state: wire.state,
145 reason,
146 };
147 value.validate().map_err(D::Error::custom)?;
148 Ok(value)
149 }
150}
151
152impl GltfAnimationCoverageV1 {
153 fn from_source(value: animsmith_core::SourceSetCoverageV1) -> Self {
154 Self {
155 state: match value.state() {
156 SourceSetCoverageStateV1::Complete => GltfAnimationCoverageStateV1::Complete,
157 SourceSetCoverageStateV1::Partial => GltfAnimationCoverageStateV1::Partial,
158 SourceSetCoverageStateV1::Unavailable => GltfAnimationCoverageStateV1::Unavailable,
159 },
160 reason: value.reason().map(Into::into),
161 }
162 }
163
164 pub const fn state(self) -> GltfAnimationCoverageStateV1 {
166 self.state
167 }
168
169 pub const fn reason(self) -> Option<GltfAnimationUnavailableReasonV1> {
171 self.reason
172 }
173
174 fn validate(self) -> Result<(), GltfAnimationAddressabilityError> {
175 if matches!(
176 (self.state, self.reason),
177 (GltfAnimationCoverageStateV1::Complete, None)
178 | (
179 GltfAnimationCoverageStateV1::Partial
180 | GltfAnimationCoverageStateV1::Unavailable,
181 Some(_)
182 )
183 ) {
184 Ok(())
185 } else {
186 Err(GltfAnimationAddressabilityError::InvalidCoverage)
187 }
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
194pub enum GltfAnimationObservationV1<T> {
195 Observed {
197 value: T,
199 },
200 ProvenAbsent,
202 Unavailable {
204 reason: GltfAnimationUnavailableReasonV1,
206 },
207}
208
209#[derive(Deserialize)]
210#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
211enum GltfAnimationObservationWireV1<T> {
212 Observed {
213 value: T,
214 },
215 ProvenAbsent,
216 Unavailable {
217 reason: GltfAnimationUnavailableReasonV1,
218 },
219}
220
221impl<T> From<GltfAnimationObservationWireV1<T>> for GltfAnimationObservationV1<T> {
222 fn from(value: GltfAnimationObservationWireV1<T>) -> Self {
223 match value {
224 GltfAnimationObservationWireV1::Observed { value } => Self::Observed { value },
225 GltfAnimationObservationWireV1::ProvenAbsent => Self::ProvenAbsent,
226 GltfAnimationObservationWireV1::Unavailable { reason } => Self::Unavailable { reason },
227 }
228 }
229}
230
231impl<T> GltfAnimationObservationV1<T> {
232 fn from_source<U>(
233 value: &animsmith_core::SourceObservationV1<U>,
234 map: impl FnOnce(&U) -> T,
235 ) -> Self {
236 match value.state() {
237 SourceObservationStateV1::Observed(value) => Self::Observed { value: map(value) },
238 SourceObservationStateV1::ProvenAbsent => Self::ProvenAbsent,
239 SourceObservationStateV1::Unavailable(reason) => Self::Unavailable {
240 reason: (*reason).into(),
241 },
242 }
243 }
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum GltfAnimationTargetKindV1 {
250 Node,
252 Element,
254 Other,
256}
257
258impl From<SourceTargetKindV1> for GltfAnimationTargetKindV1 {
259 fn from(value: SourceTargetKindV1) -> Self {
260 match value {
261 SourceTargetKindV1::Node => Self::Node,
262 SourceTargetKindV1::Element => Self::Element,
263 SourceTargetKindV1::Other => Self::Other,
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
270#[serde(deny_unknown_fields)]
271pub struct GltfAnimationTargetV1 {
272 kind: GltfAnimationTargetKindV1,
273 index: u64,
274}
275
276#[derive(Deserialize)]
277#[serde(deny_unknown_fields)]
278struct GltfAnimationTargetWireV1 {
279 kind: GltfAnimationTargetKindV1,
280 index: u64,
281}
282
283impl<'de> Deserialize<'de> for GltfAnimationTargetV1 {
284 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
285 where
286 D: Deserializer<'de>,
287 {
288 let wire = GltfAnimationTargetWireV1::deserialize(deserializer)?;
289 if wire.kind != GltfAnimationTargetKindV1::Node {
290 return Err(D::Error::custom(
291 GltfAnimationAddressabilityError::InvalidGltfTargetKind,
292 ));
293 }
294 Ok(Self {
295 kind: wire.kind,
296 index: wire.index,
297 })
298 }
299}
300
301impl GltfAnimationTargetV1 {
302 pub const fn kind(self) -> GltfAnimationTargetKindV1 {
304 self.kind
305 }
306
307 pub const fn index(self) -> u64 {
309 self.index
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum GltfAnimationChannelPropertyV1 {
317 Translation,
319 Rotation,
321 Scale,
323 Weights,
325 Other,
327}
328
329impl From<SourceChannelPropertyV1> for GltfAnimationChannelPropertyV1 {
330 fn from(value: SourceChannelPropertyV1) -> Self {
331 match value {
332 SourceChannelPropertyV1::Translation => Self::Translation,
333 SourceChannelPropertyV1::Rotation => Self::Rotation,
334 SourceChannelPropertyV1::Scale => Self::Scale,
335 SourceChannelPropertyV1::Weights => Self::Weights,
336 SourceChannelPropertyV1::Other => Self::Other,
337 }
338 }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
343#[serde(deny_unknown_fields)]
344pub struct GltfAnimationAddressabilityChannelV1 {
345 source_channel_index: u64,
346 target: GltfAnimationTargetV1,
347 property: GltfAnimationChannelPropertyV1,
348 input_accessor_index: u64,
349 output_accessor_index: u64,
350}
351
352#[derive(Deserialize)]
353#[serde(deny_unknown_fields)]
354struct GltfAnimationAddressabilityChannelWireV1 {
355 source_channel_index: u64,
356 target: GltfAnimationTargetV1,
357 property: GltfAnimationChannelPropertyV1,
358 input_accessor_index: u64,
359 output_accessor_index: u64,
360}
361
362impl<'de> Deserialize<'de> for GltfAnimationAddressabilityChannelV1 {
363 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
364 where
365 D: Deserializer<'de>,
366 {
367 let wire = GltfAnimationAddressabilityChannelWireV1::deserialize(deserializer)?;
368 let limit = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1;
369 if wire.source_channel_index >= limit as u64 {
370 return Err(D::Error::custom(
371 GltfAnimationAddressabilityError::SourceIndexOutOfRange {
372 field: "source channel index",
373 actual: wire.source_channel_index,
374 limit,
375 },
376 ));
377 }
378 Ok(Self {
379 source_channel_index: wire.source_channel_index,
380 target: wire.target,
381 property: wire.property,
382 input_accessor_index: wire.input_accessor_index,
383 output_accessor_index: wire.output_accessor_index,
384 })
385 }
386}
387
388impl GltfAnimationAddressabilityChannelV1 {
389 pub const fn source_channel_index(&self) -> u64 {
391 self.source_channel_index
392 }
393
394 pub const fn target(&self) -> GltfAnimationTargetV1 {
396 self.target
397 }
398
399 pub const fn property(&self) -> GltfAnimationChannelPropertyV1 {
401 self.property
402 }
403
404 pub const fn input_accessor_index(&self) -> u64 {
406 self.input_accessor_index
407 }
408
409 pub const fn output_accessor_index(&self) -> u64 {
411 self.output_accessor_index
412 }
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
417#[serde(deny_unknown_fields)]
418pub struct GltfAnimationAddressabilityChannelSetV1 {
419 coverage: GltfAnimationCoverageV1,
420 rows: Vec<GltfAnimationAddressabilityChannelV1>,
421}
422
423#[derive(Deserialize)]
424#[serde(deny_unknown_fields)]
425struct GltfAnimationAddressabilityChannelSetWireV1 {
426 coverage: GltfAnimationCoverageV1,
427 #[serde(deserialize_with = "deserialize_channel_rows")]
428 rows: Vec<GltfAnimationAddressabilityChannelV1>,
429}
430
431impl<'de> Deserialize<'de> for GltfAnimationAddressabilityChannelSetV1 {
432 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
433 where
434 D: Deserializer<'de>,
435 {
436 let wire = GltfAnimationAddressabilityChannelSetWireV1::deserialize(deserializer)?;
437 Self::from_wire(wire).map_err(D::Error::custom)
438 }
439}
440
441impl GltfAnimationAddressabilityChannelSetV1 {
442 fn from_wire(
443 wire: GltfAnimationAddressabilityChannelSetWireV1,
444 ) -> Result<Self, GltfAnimationAddressabilityError> {
445 if wire.coverage.state == GltfAnimationCoverageStateV1::Unavailable && !wire.rows.is_empty()
446 {
447 return Err(GltfAnimationAddressabilityError::RowsWithUnavailableCoverage);
448 }
449 if wire.rows.len() >= animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS {
450 return Err(GltfAnimationAddressabilityError::TooManyRows {
451 found: wire.rows.len(),
452 limit: animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1,
453 });
454 }
455 for (offset, row) in wire.rows.iter().enumerate() {
456 let expected = u64::try_from(offset).map_err(|_| {
457 GltfAnimationAddressabilityError::IndexOverflow {
458 field: "channel index",
459 }
460 })?;
461 if row.source_channel_index != expected {
462 return Err(GltfAnimationAddressabilityError::NonCanonicalChannelIndex {
463 source_clip_index: 0,
464 expected,
465 actual: row.source_channel_index,
466 });
467 }
468 }
469 Ok(Self {
470 coverage: wire.coverage,
471 rows: wire.rows,
472 })
473 }
474
475 pub const fn coverage(&self) -> GltfAnimationCoverageV1 {
477 self.coverage
478 }
479
480 pub fn rows(&self) -> &[GltfAnimationAddressabilityChannelV1] {
482 &self.rows
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488#[serde(deny_unknown_fields)]
489pub struct GltfAnimationAddressabilityAnimationV1 {
490 source_clip_index: u64,
491 source_name: GltfAnimationObservationV1<String>,
492 normalized_clip_index: GltfAnimationObservationV1<u64>,
493 channels: GltfAnimationAddressabilityChannelSetV1,
494}
495
496#[derive(Deserialize)]
497#[serde(deny_unknown_fields)]
498struct GltfAnimationAddressabilityAnimationWireV1 {
499 source_clip_index: u64,
500 source_name: GltfAnimationObservationWireV1<String>,
501 normalized_clip_index: GltfAnimationObservationWireV1<u64>,
502 channels: GltfAnimationAddressabilityChannelSetV1,
503}
504
505impl<'de> Deserialize<'de> for GltfAnimationAddressabilityAnimationV1 {
506 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
507 where
508 D: Deserializer<'de>,
509 {
510 let wire = GltfAnimationAddressabilityAnimationWireV1::deserialize(deserializer)?;
511 Self::from_wire(wire).map_err(D::Error::custom)
512 }
513}
514
515impl GltfAnimationAddressabilityAnimationV1 {
516 fn from_wire(
517 wire: GltfAnimationAddressabilityAnimationWireV1,
518 ) -> Result<Self, GltfAnimationAddressabilityError> {
519 if wire.source_clip_index >= animsmith_core::RAW_SOURCE_V1_MAX_CLIPS as u64 {
520 return Err(GltfAnimationAddressabilityError::SourceIndexOutOfRange {
521 field: "source clip index",
522 actual: wire.source_clip_index,
523 limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
524 });
525 }
526 if let GltfAnimationObservationWireV1::Observed { value } = &wire.source_name
527 && value.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES
528 {
529 return Err(GltfAnimationAddressabilityError::TextTooLong {
530 found: value.len(),
531 limit: animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES,
532 });
533 }
534 Ok(Self {
535 source_clip_index: wire.source_clip_index,
536 source_name: wire.source_name.into(),
537 normalized_clip_index: wire.normalized_clip_index.into(),
538 channels: wire.channels,
539 })
540 }
541
542 pub const fn source_clip_index(&self) -> u64 {
544 self.source_clip_index
545 }
546
547 pub const fn source_name(&self) -> &GltfAnimationObservationV1<String> {
549 &self.source_name
550 }
551
552 pub const fn normalized_clip_index(&self) -> &GltfAnimationObservationV1<u64> {
554 &self.normalized_clip_index
555 }
556
557 pub const fn channels(&self) -> &GltfAnimationAddressabilityChannelSetV1 {
559 &self.channels
560 }
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
565#[serde(deny_unknown_fields)]
566pub struct GltfAnimationAddressabilityAnimationSetV1 {
567 coverage: GltfAnimationCoverageV1,
568 rows: Vec<GltfAnimationAddressabilityAnimationV1>,
569}
570
571#[derive(Deserialize)]
572#[serde(deny_unknown_fields)]
573struct GltfAnimationAddressabilityAnimationSetWireV1 {
574 coverage: GltfAnimationCoverageV1,
575 #[serde(deserialize_with = "deserialize_animation_rows")]
576 rows: Vec<GltfAnimationAddressabilityAnimationV1>,
577}
578
579impl<'de> Deserialize<'de> for GltfAnimationAddressabilityAnimationSetV1 {
580 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
581 where
582 D: Deserializer<'de>,
583 {
584 let wire = GltfAnimationAddressabilityAnimationSetWireV1::deserialize(deserializer)?;
585 let value = Self {
586 coverage: wire.coverage,
587 rows: wire.rows,
588 };
589 validate_animation_set(&value).map_err(D::Error::custom)?;
590 Ok(value)
591 }
592}
593
594impl GltfAnimationAddressabilityAnimationSetV1 {
595 pub const fn coverage(&self) -> GltfAnimationCoverageV1 {
597 self.coverage
598 }
599
600 pub fn rows(&self) -> &[GltfAnimationAddressabilityAnimationV1] {
602 &self.rows
603 }
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
608pub struct GltfAnimationAddressabilityInventoryV1 {
609 schema: &'static str,
610 identity: GltfAnimationAddressabilityIdentityV1,
611 source_format: SourceFormatV1,
612 primary_input: InputIdentity,
613 dependency_closure: DependencyClosureV1,
614 animations: GltfAnimationAddressabilityAnimationSetV1,
615}
616
617#[derive(Deserialize)]
618#[serde(deny_unknown_fields)]
619struct GltfAnimationAddressabilityInventoryWireV1 {
620 schema: String,
621 identity: GltfAnimationAddressabilityIdentityV1,
622 source_format: SourceFormatV1,
623 primary_input: InputIdentity,
624 dependency_closure: DependencyClosureV1,
625 animations: GltfAnimationAddressabilityAnimationSetV1,
626}
627
628impl<'de> Deserialize<'de> for GltfAnimationAddressabilityInventoryV1 {
629 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
630 where
631 D: Deserializer<'de>,
632 {
633 let wire = GltfAnimationAddressabilityInventoryWireV1::deserialize(deserializer)?;
634 let value = Self {
635 schema: if wire.schema == GLTF_ANIMATION_ADDRESSABILITY_V1_ID {
636 GLTF_ANIMATION_ADDRESSABILITY_V1_ID
637 } else {
638 return Err(D::Error::custom(format!(
639 "inventory schema must be {GLTF_ANIMATION_ADDRESSABILITY_V1_ID:?}"
640 )));
641 },
642 identity: wire.identity,
643 source_format: wire.source_format,
644 primary_input: wire.primary_input,
645 dependency_closure: wire.dependency_closure,
646 animations: wire.animations,
647 };
648 value.validate().map_err(D::Error::custom)?;
649 Ok(value)
650 }
651}
652
653impl GltfAnimationAddressabilityInventoryV1 {
654 pub fn from_source(source: &LoadedSource) -> Result<Self, GltfAnimationAddressabilityError> {
664 let facts = source.source_facts();
665 require_gltf(facts.format())?;
666 let document_clip_count = source.document().clips.len();
667 if document_clip_count > animsmith_core::RAW_SOURCE_V1_MAX_CLIPS {
668 return Err(GltfAnimationAddressabilityError::TooManyAnimations {
669 found: document_clip_count,
670 limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
671 });
672 }
673 preflight_source(facts)?;
674
675 let mut animations = Vec::with_capacity(facts.clips().rows().len());
676 for clip in facts.clips().rows() {
677 let mut channels = Vec::with_capacity(clip.channels().rows().len());
678 for channel in clip.channels().rows() {
679 let (input_accessor_index, output_accessor_index) = match (
680 channel.input_accessor_index(),
681 channel.output_accessor_index(),
682 ) {
683 (Some(input), Some(output)) => (
684 to_u64("input accessor index", input)?,
685 to_u64("output accessor index", output)?,
686 ),
687 (None, None) => {
688 return Err(GltfAnimationAddressabilityError::MissingAccessorPair);
689 }
690 _ => {
691 return Err(GltfAnimationAddressabilityError::IncompleteAccessorPair);
692 }
693 };
694 channels.push(GltfAnimationAddressabilityChannelV1 {
695 source_channel_index: to_u64(
696 "source channel index",
697 channel.source_channel_index(),
698 )?,
699 target: GltfAnimationTargetV1 {
700 kind: channel.target().kind().into(),
701 index: channel.target().index(),
702 },
703 property: channel.property().into(),
704 input_accessor_index,
705 output_accessor_index,
706 });
707 }
708 animations.push(GltfAnimationAddressabilityAnimationV1 {
709 source_clip_index: to_u64("source clip index", clip.source_clip_index())?,
710 source_name: GltfAnimationObservationV1::from_source(clip.source_name(), |value| {
711 value.as_str().to_owned()
712 }),
713 normalized_clip_index: map_index_observation(clip.normalized_clip_index())?,
714 channels: GltfAnimationAddressabilityChannelSetV1 {
715 coverage: GltfAnimationCoverageV1::from_source(clip.channels().coverage()),
716 rows: channels,
717 },
718 });
719 }
720
721 let mut value = Self {
722 schema: GLTF_ANIMATION_ADDRESSABILITY_V1_ID,
723 identity: GltfAnimationAddressabilityIdentityV1(InputIdentity::from_bytes(&[])),
724 source_format: facts.format(),
725 primary_input: facts.primary_identity().clone(),
726 dependency_closure: source.dependency_closure().clone(),
727 animations: GltfAnimationAddressabilityAnimationSetV1 {
728 coverage: GltfAnimationCoverageV1::from_source(facts.clips().coverage()),
729 rows: animations,
730 },
731 };
732 value.validate_without_identity()?;
733 value.identity = GltfAnimationAddressabilityIdentityV1(value.computed_identity());
734 Ok(value)
735 }
736
737 pub const fn contract_id(&self) -> &'static str {
739 self.schema
740 }
741
742 pub const fn identity(&self) -> &GltfAnimationAddressabilityIdentityV1 {
744 &self.identity
745 }
746
747 pub const fn source_format(&self) -> SourceFormatV1 {
749 self.source_format
750 }
751
752 pub const fn primary_input(&self) -> &InputIdentity {
754 &self.primary_input
755 }
756
757 pub const fn dependency_closure(&self) -> &DependencyClosureV1 {
759 &self.dependency_closure
760 }
761
762 pub const fn animations(&self) -> &GltfAnimationAddressabilityAnimationSetV1 {
764 &self.animations
765 }
766
767 fn validate(&self) -> Result<(), GltfAnimationAddressabilityError> {
768 self.validate_without_identity()?;
769 if self.identity.0 != self.computed_identity() {
770 return Err(GltfAnimationAddressabilityError::InventoryIdentityMismatch);
771 }
772 Ok(())
773 }
774
775 fn validate_without_identity(&self) -> Result<(), GltfAnimationAddressabilityError> {
776 require_gltf(self.source_format)?;
777 if self.dependency_closure.primary_input() != &self.primary_input {
778 return Err(GltfAnimationAddressabilityError::DependencyClosurePrimaryMismatch);
779 }
780 validate_animation_set(&self.animations)
781 }
782
783 fn computed_identity(&self) -> InputIdentity {
784 let closure_fingerprint = self.dependency_closure.record_identity();
785 let mut encoder = CanonicalEncoder::new(INVENTORY_IDENTITY_DOMAIN);
786 encoder.field("schema");
787 encoder.token(self.schema);
788 encoder.field("source_format");
789 encoder.token(source_format_name(self.source_format));
790 encoder.field("primary_input");
791 encode_input_identity(&mut encoder, &self.primary_input);
792 encoder.field("dependency_closure_fingerprint");
793 encode_input_identity(&mut encoder, &closure_fingerprint);
794 encode_animation_set(&mut encoder, &self.animations);
795 encoder.identity()
796 }
797}
798
799#[derive(Debug, Clone, Serialize)]
801pub struct GltfAnimationAddressabilityBevyAdapterV1 {
802 prediction_provenance: PredictionProvenanceV1,
803 check: CheckEvaluation,
804}
805
806impl GltfAnimationAddressabilityBevyAdapterV1 {
807 pub fn new(
815 prediction_provenance: PredictionProvenanceV1,
816 check: CheckEvaluation,
817 inventory: &GltfAnimationAddressabilityInventoryV1,
818 ) -> Result<Self, GltfAnimationAddressabilityError> {
819 validate_bevy_adapter(
820 &prediction_provenance,
821 BevyAddressabilityCheckView::from(&check),
822 inventory,
823 )?;
824 Ok(Self {
825 prediction_provenance,
826 check,
827 })
828 }
829
830 pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
832 &self.prediction_provenance
833 }
834
835 pub const fn check(&self) -> &CheckEvaluation {
837 &self.check
838 }
839}
840
841#[derive(Debug, Clone, Serialize)]
843pub struct GltfAnimationAddressabilityV1 {
844 schema_version: u32,
845 schema: &'static str,
846 tool: ToolInfo,
847 command: &'static str,
848 input: InputIdentity,
849 inventory: GltfAnimationAddressabilityInventoryV1,
850 bevy: Option<GltfAnimationAddressabilityBevyAdapterV1>,
851}
852
853impl GltfAnimationAddressabilityV1 {
854 pub fn new(
861 tool: ToolInfo,
862 inventory: GltfAnimationAddressabilityInventoryV1,
863 bevy: Option<GltfAnimationAddressabilityBevyAdapterV1>,
864 ) -> Result<Self, GltfAnimationAddressabilityError> {
865 inventory.validate()?;
866 if let Some(adapter) = &bevy {
867 validate_bevy_adapter(
868 adapter.prediction_provenance(),
869 BevyAddressabilityCheckView::from(adapter.check()),
870 &inventory,
871 )?;
872 }
873 Ok(Self {
874 schema_version: GLTF_ANIMATION_ADDRESSABILITY_SCHEMA_VERSION,
875 schema: GLTF_ANIMATION_ADDRESSABILITY_V1_ID,
876 tool,
877 command: GLTF_ANIMATION_ADDRESSABILITY_COMMAND,
878 input: inventory.primary_input.clone(),
879 inventory,
880 bevy,
881 })
882 }
883
884 pub const fn input(&self) -> &InputIdentity {
886 &self.input
887 }
888
889 pub const fn inventory(&self) -> &GltfAnimationAddressabilityInventoryV1 {
891 &self.inventory
892 }
893
894 pub const fn bevy(&self) -> Option<&GltfAnimationAddressabilityBevyAdapterV1> {
896 self.bevy.as_ref()
897 }
898}
899
900#[derive(Debug, Deserialize)]
901#[serde(deny_unknown_fields)]
902struct ToolSourceInputV1 {
903 #[serde(default, deserialize_with = "deserialize_required_nullable")]
904 revision: RequiredNullable<String>,
905 #[serde(default, deserialize_with = "deserialize_required_nullable")]
906 dirty: RequiredNullable<bool>,
907}
908
909#[derive(Debug, Deserialize)]
910#[serde(deny_unknown_fields)]
911struct ToolInputV1 {
912 name: String,
913 version: String,
914 source: ToolSourceInputV1,
915}
916
917#[derive(Debug, Deserialize)]
918#[serde(deny_unknown_fields)]
919struct BevyAdapterInputV1 {
920 prediction_provenance: Box<RawValue>,
921 check: Box<RawValue>,
922}
923
924#[derive(Debug, Default)]
925enum RequiredNullable<T> {
926 #[default]
927 Missing,
928 Present(Option<T>),
929}
930
931fn deserialize_required_nullable<'de, D, T>(
932 deserializer: D,
933) -> Result<RequiredNullable<T>, D::Error>
934where
935 D: Deserializer<'de>,
936 T: Deserialize<'de>,
937{
938 Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
939}
940
941fn deserialize_present_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
942where
943 D: Deserializer<'de>,
944 T: Deserialize<'de>,
945{
946 T::deserialize(deserializer).map(Some)
947}
948
949#[derive(Clone, Copy)]
950enum AddressabilitySequenceBound {
951 AnimationRows,
952 ChannelRows,
953 AggregateRows,
954 EvaluatedScopes,
955 EmptyCheckEntries,
956}
957
958impl AddressabilitySequenceBound {
959 const fn limit(self) -> usize {
960 match self {
961 Self::AnimationRows | Self::EvaluatedScopes => animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
962 Self::ChannelRows => animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1,
963 Self::AggregateRows => animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS,
964 Self::EmptyCheckEntries => 0,
965 }
966 }
967
968 fn error(self) -> GltfAnimationAddressabilityError {
969 let limit = self.limit();
970 match self {
971 Self::AnimationRows => GltfAnimationAddressabilityError::TooManyAnimations {
972 found: limit + 1,
973 limit,
974 },
975 Self::ChannelRows => GltfAnimationAddressabilityError::TooManyRows {
976 found: limit + 1,
977 limit,
978 },
979 Self::AggregateRows => GltfAnimationAddressabilityError::TooManyRows {
980 found: limit + 1,
981 limit,
982 },
983 Self::EvaluatedScopes | Self::EmptyCheckEntries => {
984 GltfAnimationAddressabilityError::InvalidBevyCheckSubset
985 }
986 }
987 }
988}
989
990struct BoundedSequenceVisitor<T> {
991 bound: AddressabilitySequenceBound,
992 element: PhantomData<fn() -> T>,
993}
994
995impl<'de, T> Visitor<'de> for BoundedSequenceVisitor<T>
996where
997 T: Deserialize<'de>,
998{
999 type Value = Vec<T>;
1000
1001 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1002 write!(
1003 formatter,
1004 "a sequence with at most {} elements",
1005 self.bound.limit()
1006 )
1007 }
1008
1009 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1010 where
1011 A: SeqAccess<'de>,
1012 {
1013 let limit = self.bound.limit();
1014 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(limit));
1015 while values.len() < limit {
1016 let Some(value) = sequence.next_element()? else {
1017 return Ok(values);
1018 };
1019 values.push(value);
1020 }
1021 reject_next_element(&mut sequence, self.bound)?;
1022 Ok(values)
1023 }
1024}
1025
1026struct RejectExtraElement(AddressabilitySequenceBound);
1027
1028impl<'de> DeserializeSeed<'de> for RejectExtraElement {
1029 type Value = Infallible;
1030
1031 fn deserialize<D>(self, _deserializer: D) -> Result<Self::Value, D::Error>
1032 where
1033 D: Deserializer<'de>,
1034 {
1035 Err(D::Error::custom(self.0.error()))
1036 }
1037}
1038
1039fn reject_next_element<'de, A>(
1040 sequence: &mut A,
1041 bound: AddressabilitySequenceBound,
1042) -> Result<(), A::Error>
1043where
1044 A: SeqAccess<'de>,
1045{
1046 match sequence.next_element_seed(RejectExtraElement(bound)) {
1047 Ok(None) => Ok(()),
1048 Ok(Some(never)) => match never {},
1049 Err(error) => Err(error),
1050 }
1051}
1052
1053fn deserialize_bounded_sequence<'de, D, T>(
1054 deserializer: D,
1055 bound: AddressabilitySequenceBound,
1056) -> Result<Vec<T>, D::Error>
1057where
1058 D: Deserializer<'de>,
1059 T: Deserialize<'de>,
1060{
1061 deserializer.deserialize_seq(BoundedSequenceVisitor {
1062 bound,
1063 element: PhantomData,
1064 })
1065}
1066
1067fn deserialize_animation_rows<'de, D>(
1068 deserializer: D,
1069) -> Result<Vec<GltfAnimationAddressabilityAnimationV1>, D::Error>
1070where
1071 D: Deserializer<'de>,
1072{
1073 struct AnimationRowsVisitor;
1074
1075 impl<'de> Visitor<'de> for AnimationRowsVisitor {
1076 type Value = Vec<GltfAnimationAddressabilityAnimationV1>;
1077
1078 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1079 formatter.write_str("a bounded sequence of animation rows")
1080 }
1081
1082 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1083 where
1084 A: SeqAccess<'de>,
1085 {
1086 let animation_limit = animsmith_core::RAW_SOURCE_V1_MAX_CLIPS;
1087 let mut remaining_rows = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS;
1088 let mut animations =
1089 Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(animation_limit));
1090 loop {
1091 let bound = if animations.len() == animation_limit {
1092 Some(AddressabilitySequenceBound::AnimationRows)
1093 } else if remaining_rows == 0 {
1094 Some(AddressabilitySequenceBound::AggregateRows)
1095 } else {
1096 None
1097 };
1098 if let Some(bound) = bound {
1099 reject_next_element(&mut sequence, bound)?;
1100 return Ok(animations);
1101 }
1102 let Some(animation) = sequence.next_element_seed(AnimationRowSeed {
1103 remaining_rows: &mut remaining_rows,
1104 })?
1105 else {
1106 return Ok(animations);
1107 };
1108 animations.push(animation);
1109 }
1110 }
1111 }
1112
1113 deserializer.deserialize_seq(AnimationRowsVisitor)
1114}
1115
1116struct AnimationRowSeed<'a> {
1117 remaining_rows: &'a mut usize,
1118}
1119
1120impl<'de> DeserializeSeed<'de> for AnimationRowSeed<'_> {
1121 type Value = GltfAnimationAddressabilityAnimationV1;
1122
1123 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1124 where
1125 D: Deserializer<'de>,
1126 {
1127 *self.remaining_rows -= 1;
1128 deserializer.deserialize_struct(
1129 "GltfAnimationAddressabilityAnimationV1",
1130 ANIMATION_ROW_FIELDS,
1131 AnimationRowVisitor {
1132 remaining_rows: self.remaining_rows,
1133 },
1134 )
1135 }
1136}
1137
1138const ANIMATION_ROW_FIELDS: &[&str] = &[
1139 "source_clip_index",
1140 "source_name",
1141 "normalized_clip_index",
1142 "channels",
1143];
1144
1145#[derive(Deserialize)]
1146#[serde(field_identifier, rename_all = "snake_case")]
1147enum AnimationRowField {
1148 SourceClipIndex,
1149 SourceName,
1150 NormalizedClipIndex,
1151 Channels,
1152}
1153
1154struct AnimationRowVisitor<'a> {
1155 remaining_rows: &'a mut usize,
1156}
1157
1158impl<'de> Visitor<'de> for AnimationRowVisitor<'_> {
1159 type Value = GltfAnimationAddressabilityAnimationV1;
1160
1161 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1162 formatter.write_str("a strict animation row object")
1163 }
1164
1165 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1166 where
1167 A: MapAccess<'de>,
1168 {
1169 let mut source_clip_index = None;
1170 let mut source_name = None;
1171 let mut normalized_clip_index = None;
1172 let mut channels = None;
1173 while let Some(field) = map.next_key()? {
1174 match field {
1175 AnimationRowField::SourceClipIndex => {
1176 if source_clip_index.is_some() {
1177 return Err(A::Error::duplicate_field("source_clip_index"));
1178 }
1179 source_clip_index = Some(map.next_value()?);
1180 }
1181 AnimationRowField::SourceName => {
1182 if source_name.is_some() {
1183 return Err(A::Error::duplicate_field("source_name"));
1184 }
1185 source_name = Some(map.next_value()?);
1186 }
1187 AnimationRowField::NormalizedClipIndex => {
1188 if normalized_clip_index.is_some() {
1189 return Err(A::Error::duplicate_field("normalized_clip_index"));
1190 }
1191 normalized_clip_index = Some(map.next_value()?);
1192 }
1193 AnimationRowField::Channels => {
1194 if channels.is_some() {
1195 return Err(A::Error::duplicate_field("channels"));
1196 }
1197 channels = Some(map.next_value_seed(ChannelSetSeed {
1198 remaining_rows: self.remaining_rows,
1199 })?);
1200 }
1201 }
1202 }
1203 let wire = GltfAnimationAddressabilityAnimationWireV1 {
1204 source_clip_index: source_clip_index
1205 .ok_or_else(|| A::Error::missing_field("source_clip_index"))?,
1206 source_name: source_name.ok_or_else(|| A::Error::missing_field("source_name"))?,
1207 normalized_clip_index: normalized_clip_index
1208 .ok_or_else(|| A::Error::missing_field("normalized_clip_index"))?,
1209 channels: channels.ok_or_else(|| A::Error::missing_field("channels"))?,
1210 };
1211 GltfAnimationAddressabilityAnimationV1::from_wire(wire).map_err(A::Error::custom)
1212 }
1213}
1214
1215struct ChannelSetSeed<'a> {
1216 remaining_rows: &'a mut usize,
1217}
1218
1219impl<'de> DeserializeSeed<'de> for ChannelSetSeed<'_> {
1220 type Value = GltfAnimationAddressabilityChannelSetV1;
1221
1222 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1223 where
1224 D: Deserializer<'de>,
1225 {
1226 deserializer.deserialize_struct(
1227 "GltfAnimationAddressabilityChannelSetV1",
1228 CHANNEL_SET_FIELDS,
1229 ChannelSetVisitor {
1230 remaining_rows: self.remaining_rows,
1231 },
1232 )
1233 }
1234}
1235
1236const CHANNEL_SET_FIELDS: &[&str] = &["coverage", "rows"];
1237
1238#[derive(Deserialize)]
1239#[serde(field_identifier, rename_all = "snake_case")]
1240enum ChannelSetField {
1241 Coverage,
1242 Rows,
1243}
1244
1245struct ChannelSetVisitor<'a> {
1246 remaining_rows: &'a mut usize,
1247}
1248
1249impl<'de> Visitor<'de> for ChannelSetVisitor<'_> {
1250 type Value = GltfAnimationAddressabilityChannelSetV1;
1251
1252 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1253 formatter.write_str("a strict animation channel-set object")
1254 }
1255
1256 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1257 where
1258 A: MapAccess<'de>,
1259 {
1260 let mut coverage = None;
1261 let mut rows = None;
1262 while let Some(field) = map.next_key()? {
1263 match field {
1264 ChannelSetField::Coverage => {
1265 if coverage.is_some() {
1266 return Err(A::Error::duplicate_field("coverage"));
1267 }
1268 coverage = Some(map.next_value()?);
1269 }
1270 ChannelSetField::Rows => {
1271 if rows.is_some() {
1272 return Err(A::Error::duplicate_field("rows"));
1273 }
1274 rows = Some(map.next_value_seed(AggregateChannelRowsSeed {
1275 remaining_rows: self.remaining_rows,
1276 })?);
1277 }
1278 }
1279 }
1280 let wire = GltfAnimationAddressabilityChannelSetWireV1 {
1281 coverage: coverage.ok_or_else(|| A::Error::missing_field("coverage"))?,
1282 rows: rows.ok_or_else(|| A::Error::missing_field("rows"))?,
1283 };
1284 GltfAnimationAddressabilityChannelSetV1::from_wire(wire).map_err(A::Error::custom)
1285 }
1286}
1287
1288struct AggregateChannelRowsSeed<'a> {
1289 remaining_rows: &'a mut usize,
1290}
1291
1292impl<'de> DeserializeSeed<'de> for AggregateChannelRowsSeed<'_> {
1293 type Value = Vec<GltfAnimationAddressabilityChannelV1>;
1294
1295 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1296 where
1297 D: Deserializer<'de>,
1298 {
1299 deserializer.deserialize_seq(AggregateChannelRowsVisitor {
1300 remaining_rows: self.remaining_rows,
1301 })
1302 }
1303}
1304
1305struct AggregateChannelRowsVisitor<'a> {
1306 remaining_rows: &'a mut usize,
1307}
1308
1309impl<'de> Visitor<'de> for AggregateChannelRowsVisitor<'_> {
1310 type Value = Vec<GltfAnimationAddressabilityChannelV1>;
1311
1312 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1313 formatter.write_str("a channel-row sequence within the remaining aggregate row budget")
1314 }
1315
1316 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1317 where
1318 A: SeqAccess<'de>,
1319 {
1320 let channel_limit = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1;
1321 let limit = (*self.remaining_rows).min(channel_limit);
1322 let mut rows = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(limit));
1323 while rows.len() < limit {
1324 let Some(row) = sequence.next_element()? else {
1325 return Ok(rows);
1326 };
1327 rows.push(row);
1328 *self.remaining_rows -= 1;
1329 }
1330 let bound = if limit == channel_limit {
1331 AddressabilitySequenceBound::ChannelRows
1332 } else {
1333 AddressabilitySequenceBound::AggregateRows
1334 };
1335 reject_next_element(&mut sequence, bound)?;
1336 Ok(rows)
1337 }
1338}
1339
1340fn deserialize_channel_rows<'de, D>(
1341 deserializer: D,
1342) -> Result<Vec<GltfAnimationAddressabilityChannelV1>, D::Error>
1343where
1344 D: Deserializer<'de>,
1345{
1346 deserialize_bounded_sequence(deserializer, AddressabilitySequenceBound::ChannelRows)
1347}
1348
1349fn deserialize_evaluated_scopes<'de, D>(deserializer: D) -> Result<Vec<EvaluationScope>, D::Error>
1350where
1351 D: Deserializer<'de>,
1352{
1353 deserialize_bounded_sequence(deserializer, AddressabilitySequenceBound::EvaluatedScopes)
1354}
1355
1356#[derive(Debug, Default)]
1357struct EmptyCheckSequence;
1358
1359impl<'de> Deserialize<'de> for EmptyCheckSequence {
1360 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1361 where
1362 D: Deserializer<'de>,
1363 {
1364 struct EmptyCheckSequenceVisitor;
1365
1366 impl<'de> Visitor<'de> for EmptyCheckSequenceVisitor {
1367 type Value = EmptyCheckSequence;
1368
1369 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1370 formatter.write_str("an empty sequence")
1371 }
1372
1373 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1374 where
1375 A: SeqAccess<'de>,
1376 {
1377 reject_next_element(
1378 &mut sequence,
1379 AddressabilitySequenceBound::EmptyCheckEntries,
1380 )?;
1381 Ok(EmptyCheckSequence)
1382 }
1383 }
1384
1385 deserializer.deserialize_seq(EmptyCheckSequenceVisitor)
1386 }
1387}
1388
1389fn required_nullable_value<T>(value: RequiredNullable<T>) -> Option<T> {
1390 match value {
1391 RequiredNullable::Present(value) => value,
1392 RequiredNullable::Missing => {
1393 unreachable!("validated required-nullable tool field cannot be missing")
1394 }
1395 }
1396}
1397
1398#[derive(Debug, Deserialize)]
1400#[serde(deny_unknown_fields)]
1401pub struct GltfAnimationAddressabilityInput {
1402 schema_version: u32,
1403 schema: String,
1404 tool: ToolInputV1,
1405 command: String,
1406 input: InputIdentity,
1407 inventory: Box<RawValue>,
1408 #[serde(default, deserialize_with = "deserialize_required_nullable")]
1409 bevy: RequiredNullable<BevyAdapterInputV1>,
1410}
1411
1412impl GltfAnimationAddressabilityInput {
1413 pub fn read_from(reader: impl Read) -> Result<Self, GltfAnimationAddressabilityReadError> {
1420 Self::read_from_with_limit(reader, GLTF_ANIMATION_ADDRESSABILITY_V1_MAX_REPORT_BYTES)
1421 }
1422
1423 fn read_from_with_limit(
1424 reader: impl Read,
1425 limit: u64,
1426 ) -> Result<Self, GltfAnimationAddressabilityReadError> {
1427 let mut bounded = reader.take(limit.saturating_add(1));
1428 let mut bytes = Vec::new();
1429 bounded
1430 .read_to_end(&mut bytes)
1431 .map_err(|source| GltfAnimationAddressabilityReadError::Io { source })?;
1432 if bytes.len() as u64 > limit {
1433 return Err(GltfAnimationAddressabilityReadError::ReportTooLarge { limit });
1434 }
1435 serde_json::from_slice(&bytes)
1436 .map_err(|source| GltfAnimationAddressabilityReadError::InvalidJson { source })
1437 }
1438
1439 pub fn into_report(
1446 self,
1447 ) -> Result<GltfAnimationAddressabilityReadbackV1, GltfAnimationAddressabilityError> {
1448 if self.schema_version != GLTF_ANIMATION_ADDRESSABILITY_SCHEMA_VERSION {
1449 return Err(GltfAnimationAddressabilityError::UnsupportedSchemaVersion {
1450 found: self.schema_version,
1451 });
1452 }
1453 if self.schema != GLTF_ANIMATION_ADDRESSABILITY_V1_ID {
1454 return Err(GltfAnimationAddressabilityError::WrongSchema);
1455 }
1456 if self.command != GLTF_ANIMATION_ADDRESSABILITY_COMMAND {
1457 return Err(GltfAnimationAddressabilityError::WrongCommand);
1458 }
1459 validate_tool(&self.tool)?;
1460 let inventory: GltfAnimationAddressabilityInventoryV1 =
1461 serde_json::from_str(self.inventory.get()).map_err(|source| {
1462 GltfAnimationAddressabilityError::InvalidInventoryShape {
1463 reason: source.to_string(),
1464 }
1465 })?;
1466 if self.input != *inventory.primary_input() {
1467 return Err(GltfAnimationAddressabilityError::RootInputMismatch);
1468 }
1469 let bevy = match self.bevy {
1470 RequiredNullable::Missing => {
1471 return Err(GltfAnimationAddressabilityError::MissingBevyField);
1472 }
1473 RequiredNullable::Present(None) => None,
1474 RequiredNullable::Present(Some(adapter)) => {
1475 let prediction_provenance: PredictionProvenanceV1 =
1476 serde_json::from_str(adapter.prediction_provenance.get()).map_err(
1477 |source| GltfAnimationAddressabilityError::InvalidBevyProvenance {
1478 reason: source.to_string(),
1479 },
1480 )?;
1481 let check_wire: GltfAnimationAddressabilityCheckWireV1 =
1482 serde_json::from_str(adapter.check.get()).map_err(|source| {
1483 GltfAnimationAddressabilityError::InvalidBevyCheckShape {
1484 reason: source.to_string(),
1485 }
1486 })?;
1487 let check = GltfAnimationAddressabilityCheckReadbackV1::from_wire(check_wire)?;
1488 check.validate(&prediction_provenance, &inventory)?;
1489 Some(GltfAnimationAddressabilityBevyReadbackV1 {
1490 prediction_provenance,
1491 check,
1492 })
1493 }
1494 };
1495 Ok(GltfAnimationAddressabilityReadbackV1 {
1496 tool: GltfAnimationAddressabilityToolReadbackV1 {
1497 name: self.tool.name,
1498 version: self.tool.version,
1499 revision: required_nullable_value(self.tool.source.revision),
1500 dirty: required_nullable_value(self.tool.source.dirty),
1501 },
1502 input: self.input,
1503 inventory,
1504 bevy,
1505 })
1506 }
1507}
1508
1509#[derive(Debug, Clone, PartialEq, Eq)]
1511pub struct GltfAnimationAddressabilityToolReadbackV1 {
1512 name: String,
1513 version: String,
1514 revision: Option<String>,
1515 dirty: Option<bool>,
1516}
1517
1518impl GltfAnimationAddressabilityToolReadbackV1 {
1519 pub fn name(&self) -> &str {
1521 &self.name
1522 }
1523
1524 pub fn version(&self) -> &str {
1526 &self.version
1527 }
1528
1529 pub fn revision(&self) -> Option<&str> {
1531 self.revision.as_deref()
1532 }
1533
1534 pub const fn dirty(&self) -> Option<bool> {
1536 self.dirty
1537 }
1538}
1539
1540#[derive(Debug, Clone)]
1542pub struct GltfAnimationAddressabilityCheckReadbackV1 {
1543 check_id: String,
1544 selection: SelectionState,
1545 configuration: ConfigurationState,
1546 applicability: Applicability,
1547 evaluation: EvaluationState,
1548 evaluated_scopes: Vec<EvaluationScope>,
1549 prediction: Option<EnginePredictionV1>,
1550}
1551
1552#[derive(Debug, Deserialize)]
1553#[serde(deny_unknown_fields)]
1554struct GltfAnimationAddressabilityCheckWireV1 {
1555 check_id: String,
1556 selection: SelectionState,
1557 configuration: ConfigurationState,
1558 applicability: Applicability,
1559 evaluation: EvaluationState,
1560 #[serde(rename = "findings")]
1561 _findings: EmptyCheckSequence,
1562 #[serde(default, deserialize_with = "deserialize_evaluated_scopes")]
1563 evaluated_scopes: Vec<EvaluationScope>,
1564 #[serde(default)]
1565 #[serde(rename = "gaps")]
1566 _gaps: EmptyCheckSequence,
1567 #[serde(default, deserialize_with = "deserialize_present_non_null")]
1568 prediction: Option<Box<RawValue>>,
1569}
1570
1571impl GltfAnimationAddressabilityCheckReadbackV1 {
1572 fn from_wire(
1573 wire: GltfAnimationAddressabilityCheckWireV1,
1574 ) -> Result<Self, GltfAnimationAddressabilityError> {
1575 let prediction = wire
1576 .prediction
1577 .map(|raw| {
1578 let mut deserializer = serde_json::Deserializer::from_str(raw.get());
1579 let prediction = EnginePredictionV1::deserialize_with_file_limits(
1580 &mut deserializer,
1581 PREDICTION_V1_MAX_FACETS_PER_FILE,
1582 PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
1583 )
1584 .map_err(|source| {
1585 GltfAnimationAddressabilityError::InvalidBevyCheckShape {
1586 reason: source.to_string(),
1587 }
1588 })?;
1589 deserializer.end().map_err(|source| {
1590 GltfAnimationAddressabilityError::InvalidBevyCheckShape {
1591 reason: source.to_string(),
1592 }
1593 })?;
1594 Ok(prediction)
1595 })
1596 .transpose()?;
1597 Ok(Self {
1598 check_id: wire.check_id,
1599 selection: wire.selection,
1600 configuration: wire.configuration,
1601 applicability: wire.applicability,
1602 evaluation: wire.evaluation,
1603 evaluated_scopes: wire.evaluated_scopes,
1604 prediction,
1605 })
1606 }
1607
1608 pub fn check_id(&self) -> &str {
1610 &self.check_id
1611 }
1612
1613 pub const fn selection(&self) -> SelectionState {
1615 self.selection
1616 }
1617
1618 pub const fn configuration(&self) -> ConfigurationState {
1620 self.configuration
1621 }
1622
1623 pub const fn applicability(&self) -> Applicability {
1625 self.applicability
1626 }
1627
1628 pub const fn evaluation(&self) -> EvaluationState {
1630 self.evaluation
1631 }
1632
1633 pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
1635 &self.evaluated_scopes
1636 }
1637
1638 pub const fn prediction(&self) -> Option<&EnginePredictionV1> {
1640 self.prediction.as_ref()
1641 }
1642
1643 fn validate(
1644 &self,
1645 provenance: &PredictionProvenanceV1,
1646 inventory: &GltfAnimationAddressabilityInventoryV1,
1647 ) -> Result<(), GltfAnimationAddressabilityError> {
1648 validate_bevy_adapter(
1649 provenance,
1650 BevyAddressabilityCheckView::from(self),
1651 inventory,
1652 )
1653 }
1654}
1655
1656#[derive(Debug, Clone)]
1658pub struct GltfAnimationAddressabilityBevyReadbackV1 {
1659 prediction_provenance: PredictionProvenanceV1,
1660 check: GltfAnimationAddressabilityCheckReadbackV1,
1661}
1662
1663impl GltfAnimationAddressabilityBevyReadbackV1 {
1664 pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
1666 &self.prediction_provenance
1667 }
1668
1669 pub const fn check(&self) -> &GltfAnimationAddressabilityCheckReadbackV1 {
1671 &self.check
1672 }
1673}
1674
1675#[derive(Debug, Clone)]
1677pub struct GltfAnimationAddressabilityReadbackV1 {
1678 tool: GltfAnimationAddressabilityToolReadbackV1,
1679 input: InputIdentity,
1680 inventory: GltfAnimationAddressabilityInventoryV1,
1681 bevy: Option<GltfAnimationAddressabilityBevyReadbackV1>,
1682}
1683
1684impl GltfAnimationAddressabilityReadbackV1 {
1685 pub const fn tool(&self) -> &GltfAnimationAddressabilityToolReadbackV1 {
1687 &self.tool
1688 }
1689
1690 pub const fn input(&self) -> &InputIdentity {
1692 &self.input
1693 }
1694
1695 pub const fn inventory(&self) -> &GltfAnimationAddressabilityInventoryV1 {
1697 &self.inventory
1698 }
1699
1700 pub const fn bevy(&self) -> Option<&GltfAnimationAddressabilityBevyReadbackV1> {
1702 self.bevy.as_ref()
1703 }
1704}
1705
1706#[derive(Debug, thiserror::Error)]
1708#[non_exhaustive]
1709pub enum GltfAnimationAddressabilityReadError {
1710 #[error("cannot read addressability report: {source}")]
1712 Io {
1713 #[source]
1715 source: std::io::Error,
1716 },
1717 #[error("addressability report exceeds the V1 limit of {limit} bytes")]
1719 ReportTooLarge {
1720 limit: u64,
1722 },
1723 #[error("invalid addressability report JSON: {source}")]
1725 InvalidJson {
1726 #[source]
1728 source: serde_json::Error,
1729 },
1730}
1731
1732#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1734#[non_exhaustive]
1735pub enum GltfAnimationAddressabilityError {
1736 #[error("source format {format:?} is not supported by glTF animation addressability V1")]
1738 UnsupportedSourceFormat {
1739 format: SourceFormatV1,
1741 },
1742 #[error("{field} cannot be represented as an unsigned 64-bit integer")]
1744 IndexOverflow {
1745 field: &'static str,
1747 },
1748 #[error("{field} {actual} is outside the V1 limit of {limit} rows")]
1750 SourceIndexOutOfRange {
1751 field: &'static str,
1753 actual: u64,
1755 limit: usize,
1757 },
1758 #[error("checked arithmetic overflow while validating {field}")]
1760 ArithmeticOverflow {
1761 field: &'static str,
1763 },
1764 #[error("inventory contains {found} animations, exceeding the V1 limit of {limit}")]
1766 TooManyAnimations {
1767 found: usize,
1769 limit: usize,
1771 },
1772 #[error("inventory contains {found} animation/channel rows, exceeding the V1 limit of {limit}")]
1774 TooManyRows {
1775 found: usize,
1777 limit: usize,
1779 },
1780 #[error("source name is {found} bytes, exceeding the V1 limit of {limit}")]
1782 TextTooLong {
1783 found: usize,
1785 limit: usize,
1787 },
1788 #[error("inventory retains {found} source-name bytes, exceeding the V1 limit of {limit}")]
1790 TooMuchText {
1791 found: usize,
1793 limit: usize,
1795 },
1796 #[error("coverage reason must be absent exactly for complete coverage")]
1798 InvalidCoverage,
1799 #[error("unavailable coverage must retain no rows")]
1801 RowsWithUnavailableCoverage,
1802 #[error("animation row index {actual} does not match expected source index {expected}")]
1804 NonCanonicalAnimationIndex {
1805 expected: u64,
1807 actual: u64,
1809 },
1810 #[error(
1812 "animation {source_clip_index} channel index {actual} does not match expected {expected}"
1813 )]
1814 NonCanonicalChannelIndex {
1815 source_clip_index: u64,
1817 expected: u64,
1819 actual: u64,
1821 },
1822 #[error("channel accessor identities must be both present or both absent")]
1824 IncompleteAccessorPair,
1825 #[error("retained glTF channels must carry exact input and output accessor identities")]
1827 MissingAccessorPair,
1828 #[error("retained glTF channels must target source nodes")]
1830 InvalidGltfTargetKind,
1831 #[error("dependency closure primary input does not match the inventory")]
1833 DependencyClosurePrimaryMismatch,
1834 #[error("inventory identity does not match its canonical engine-neutral preimage")]
1836 InventoryIdentityMismatch,
1837 #[error("addressability report has schema_version {found}; this build reads version 1")]
1839 UnsupportedSchemaVersion {
1840 found: u32,
1842 },
1843 #[error("addressability report has the wrong schema identity")]
1845 WrongSchema,
1846 #[error("addressability report has the wrong command discriminator")]
1848 WrongCommand,
1849 #[error("addressability report has an invalid tool identity")]
1851 InvalidTool,
1852 #[error("addressability report has an invalid inventory: {reason}")]
1854 InvalidInventoryShape {
1855 reason: String,
1857 },
1858 #[error("root input does not match the inventory primary input")]
1860 RootInputMismatch,
1861 #[error("addressability report has no required `bevy` field")]
1863 MissingBevyField,
1864 #[error("addressability report has invalid Bevy provenance: {reason}")]
1866 InvalidBevyProvenance {
1867 reason: String,
1869 },
1870 #[error("addressability report has an invalid Bevy check: {reason}")]
1872 InvalidBevyCheckShape {
1873 reason: String,
1875 },
1876 #[error("addressability adapter is not the exact Bevy 0.19.0 glTF profile subset")]
1878 InvalidBevyProfile,
1879 #[error("Bevy provenance does not bind to the engine-neutral inventory")]
1881 BevyInventoryMismatch,
1882 #[error("Bevy adapter check is not the exact engine-addressability subset")]
1884 InvalidBevyCheckSubset,
1885 #[error("invalid Bevy prediction: {0}")]
1887 InvalidPrediction(PredictionContractError),
1888}
1889
1890fn require_gltf(format: SourceFormatV1) -> Result<(), GltfAnimationAddressabilityError> {
1891 if matches!(format, SourceFormatV1::GltfJson | SourceFormatV1::Glb) {
1892 Ok(())
1893 } else {
1894 Err(GltfAnimationAddressabilityError::UnsupportedSourceFormat { format })
1895 }
1896}
1897
1898fn to_u64(field: &'static str, value: usize) -> Result<u64, GltfAnimationAddressabilityError> {
1899 u64::try_from(value).map_err(|_| GltfAnimationAddressabilityError::IndexOverflow { field })
1900}
1901
1902fn map_index_observation(
1903 value: &animsmith_core::SourceObservationV1<usize>,
1904) -> Result<GltfAnimationObservationV1<u64>, GltfAnimationAddressabilityError> {
1905 Ok(match value.state() {
1906 SourceObservationStateV1::Observed(value) => GltfAnimationObservationV1::Observed {
1907 value: to_u64("normalized clip index", *value)?,
1908 },
1909 SourceObservationStateV1::ProvenAbsent => GltfAnimationObservationV1::ProvenAbsent,
1910 SourceObservationStateV1::Unavailable(reason) => GltfAnimationObservationV1::Unavailable {
1911 reason: (*reason).into(),
1912 },
1913 })
1914}
1915
1916fn preflight_source(
1917 facts: animsmith_core::SourceFactsViewV1<'_>,
1918) -> Result<(), GltfAnimationAddressabilityError> {
1919 let mut rows = facts.clips().rows().len();
1920 let mut text = 0usize;
1921 for clip in facts.clips().rows() {
1922 rows = rows.checked_add(clip.channels().rows().len()).ok_or(
1923 GltfAnimationAddressabilityError::ArithmeticOverflow {
1924 field: "animation/channel rows",
1925 },
1926 )?;
1927 if let SourceObservationStateV1::Observed(name) = clip.source_name().state() {
1928 text = text.checked_add(name.as_str().len()).ok_or(
1929 GltfAnimationAddressabilityError::ArithmeticOverflow {
1930 field: "source-name text",
1931 },
1932 )?;
1933 }
1934 }
1935 if facts.clips().rows().len() > animsmith_core::RAW_SOURCE_V1_MAX_CLIPS {
1936 return Err(GltfAnimationAddressabilityError::TooManyAnimations {
1937 found: facts.clips().rows().len(),
1938 limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
1939 });
1940 }
1941 if rows > animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS {
1942 return Err(GltfAnimationAddressabilityError::TooManyRows {
1943 found: rows,
1944 limit: animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS,
1945 });
1946 }
1947 if text > animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES {
1948 return Err(GltfAnimationAddressabilityError::TooMuchText {
1949 found: text,
1950 limit: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES,
1951 });
1952 }
1953 Ok(())
1954}
1955
1956fn validate_animation_set(
1957 animations: &GltfAnimationAddressabilityAnimationSetV1,
1958) -> Result<(), GltfAnimationAddressabilityError> {
1959 animations.coverage.validate()?;
1960 if animations.coverage.state == GltfAnimationCoverageStateV1::Unavailable
1961 && !animations.rows.is_empty()
1962 {
1963 return Err(GltfAnimationAddressabilityError::RowsWithUnavailableCoverage);
1964 }
1965 if animations.rows.len() > animsmith_core::RAW_SOURCE_V1_MAX_CLIPS {
1966 return Err(GltfAnimationAddressabilityError::TooManyAnimations {
1967 found: animations.rows.len(),
1968 limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
1969 });
1970 }
1971 let mut total_rows = animations.rows.len();
1972 let mut total_text = 0usize;
1973 for (animation_offset, animation) in animations.rows.iter().enumerate() {
1974 let expected = to_u64("expected animation index", animation_offset)?;
1975 if animation.source_clip_index != expected {
1976 return Err(
1977 GltfAnimationAddressabilityError::NonCanonicalAnimationIndex {
1978 expected,
1979 actual: animation.source_clip_index,
1980 },
1981 );
1982 }
1983 animation.channels.coverage.validate()?;
1984 if animation.channels.coverage.state == GltfAnimationCoverageStateV1::Unavailable
1985 && !animation.channels.rows.is_empty()
1986 {
1987 return Err(GltfAnimationAddressabilityError::RowsWithUnavailableCoverage);
1988 }
1989 total_rows = total_rows
1990 .checked_add(animation.channels.rows.len())
1991 .ok_or(GltfAnimationAddressabilityError::ArithmeticOverflow {
1992 field: "animation/channel rows",
1993 })?;
1994 for (channel_offset, channel) in animation.channels.rows.iter().enumerate() {
1995 let expected = to_u64("expected channel index", channel_offset)?;
1996 if channel.source_channel_index != expected {
1997 return Err(GltfAnimationAddressabilityError::NonCanonicalChannelIndex {
1998 source_clip_index: animation.source_clip_index,
1999 expected,
2000 actual: channel.source_channel_index,
2001 });
2002 }
2003 if channel.target.kind != GltfAnimationTargetKindV1::Node {
2004 return Err(GltfAnimationAddressabilityError::InvalidGltfTargetKind);
2005 }
2006 }
2007 if let GltfAnimationObservationV1::Observed { value } = &animation.source_name {
2008 if value.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES {
2009 return Err(GltfAnimationAddressabilityError::TextTooLong {
2010 found: value.len(),
2011 limit: animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES,
2012 });
2013 }
2014 total_text = total_text.checked_add(value.len()).ok_or(
2015 GltfAnimationAddressabilityError::ArithmeticOverflow {
2016 field: "source-name text",
2017 },
2018 )?;
2019 }
2020 }
2021 if total_rows > animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS {
2022 return Err(GltfAnimationAddressabilityError::TooManyRows {
2023 found: total_rows,
2024 limit: animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS,
2025 });
2026 }
2027 if total_text > animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES {
2028 return Err(GltfAnimationAddressabilityError::TooMuchText {
2029 found: total_text,
2030 limit: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES,
2031 });
2032 }
2033 Ok(())
2034}
2035
2036#[derive(Clone, Copy)]
2037struct BevyAddressabilityCheckView<'a> {
2038 check_id: &'a str,
2039 selection: SelectionState,
2040 configuration: ConfigurationState,
2041 applicability: Applicability,
2042 evaluation: EvaluationState,
2043 finding_count: usize,
2044 evaluated_scopes: &'a [EvaluationScope],
2045 gap_count: usize,
2046 prediction: Option<&'a EnginePredictionV1>,
2047}
2048
2049impl<'a> From<&'a CheckEvaluation> for BevyAddressabilityCheckView<'a> {
2050 fn from(check: &'a CheckEvaluation) -> Self {
2051 Self {
2052 check_id: check.check_id(),
2053 selection: check.selection(),
2054 configuration: check.configuration(),
2055 applicability: check.applicability(),
2056 evaluation: check.evaluation(),
2057 finding_count: check.findings().len(),
2058 evaluated_scopes: check.evaluated_scopes(),
2059 gap_count: check.gaps().len(),
2060 prediction: check.engine_prediction(),
2061 }
2062 }
2063}
2064
2065impl<'a> From<&'a GltfAnimationAddressabilityCheckReadbackV1> for BevyAddressabilityCheckView<'a> {
2066 fn from(check: &'a GltfAnimationAddressabilityCheckReadbackV1) -> Self {
2067 Self {
2068 check_id: &check.check_id,
2069 selection: check.selection,
2070 configuration: check.configuration,
2071 applicability: check.applicability,
2072 evaluation: check.evaluation,
2073 finding_count: 0,
2074 evaluated_scopes: &check.evaluated_scopes,
2075 gap_count: 0,
2076 prediction: check.prediction.as_ref(),
2077 }
2078 }
2079}
2080
2081fn validate_bevy_adapter(
2082 provenance: &PredictionProvenanceV1,
2083 check: BevyAddressabilityCheckView<'_>,
2084 inventory: &GltfAnimationAddressabilityInventoryV1,
2085) -> Result<(), GltfAnimationAddressabilityError> {
2086 provenance
2087 .validate()
2088 .map_err(GltfAnimationAddressabilityError::InvalidPrediction)?;
2089 let profile = provenance.profile();
2090 let profile_selection = profile.selection();
2091 if profile_selection.family() != BEVY_FAMILY
2092 || profile_selection.profile_revision() != BEVY_PROFILE_REVISION
2093 || profile_selection.engine_version() != BEVY_ENGINE_VERSION
2094 || profile_selection.importer() != BEVY_IMPORTER
2095 || profile.facts_identity().sha256() != BEVY_PROFILE_FACTS_SHA256
2096 || profile.facts_identity().bytes() != BEVY_PROFILE_FACTS_CANONICAL_BYTES
2097 || !matches!(
2098 profile
2099 .fact(EngineFactIdV1::AnimationAddressability)
2100 .map(|fact| fact.state()),
2101 Some(EngineFactStateV1::Known(
2102 EngineFactValueV1::AnimationAddressability(
2103 EngineAnimationAddressabilityV1::GltfAssetLabel
2104 )
2105 ))
2106 )
2107 || profile.source(BEVY_ANIMATION_LABEL_SOURCE).is_none()
2108 {
2109 return Err(GltfAnimationAddressabilityError::InvalidBevyProfile);
2110 }
2111 validate_inventory_provenance_binding(provenance, inventory)?;
2112 if check.check_id != ENGINE_ADDRESSABILITY_CHECK_ID
2113 || check.selection != SelectionState::Selected
2114 || check.finding_count != 0
2115 || check.gap_count != 0
2116 {
2117 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2118 }
2119 let expected_applicability = if inventory.animations.coverage.state
2120 == GltfAnimationCoverageStateV1::Complete
2121 && inventory.animations.rows.is_empty()
2122 {
2123 Applicability::NotApplicable
2124 } else {
2125 Applicability::Applicable
2126 };
2127 if check.applicability != expected_applicability {
2128 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2129 }
2130 if check.configuration == ConfigurationState::Disabled {
2131 if check.evaluation != EvaluationState::NotEvaluated
2132 || !check.evaluated_scopes.is_empty()
2133 || check.prediction.is_some()
2134 {
2135 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2136 }
2137 return Ok(());
2138 }
2139 if let Some(prediction) = check.prediction {
2140 prediction
2141 .validate_against_provenance(provenance)
2142 .map_err(GltfAnimationAddressabilityError::InvalidPrediction)?;
2143 }
2144
2145 let rows = inventory.animations.rows();
2146 match inventory.animations.coverage.state {
2147 GltfAnimationCoverageStateV1::Complete if rows.is_empty() => {
2148 if check.applicability != Applicability::NotApplicable
2149 || check.evaluation != EvaluationState::NotEvaluated
2150 || !check.evaluated_scopes.is_empty()
2151 || check.prediction.is_some()
2152 {
2153 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2154 }
2155 }
2156 GltfAnimationCoverageStateV1::Complete => {
2157 let prediction = check
2158 .prediction
2159 .ok_or(GltfAnimationAddressabilityError::InvalidBevyCheckSubset)?;
2160 if check.applicability != Applicability::Applicable
2161 || check.evaluation != EvaluationState::Complete
2162 || prediction.facets().len() != rows.len()
2163 || check.evaluated_scopes.len() != rows.len()
2164 {
2165 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2166 }
2167 for (row, evaluated) in rows.iter().zip(check.evaluated_scopes) {
2168 let source_clip_index = usize::try_from(row.source_clip_index).map_err(|_| {
2169 GltfAnimationAddressabilityError::IndexOverflow {
2170 field: "source clip index",
2171 }
2172 })?;
2173 let label = crate::BevyAnimationAssetLabelV1::new(source_clip_index)
2174 .map_err(|_| GltfAnimationAddressabilityError::InvalidBevyCheckSubset)?;
2175 let expected = EvaluationScope::new(
2176 animsmith_core::EvaluationScopeCode::ANIMATION_ASSET_LABEL,
2177 )
2178 .subject(label.as_str().to_owned());
2179 let Some(facet) = prediction.facets().iter().find(|facet| {
2180 facet.state() == EnginePredictionFacetStateV1::Available
2181 && facet.scope() == &expected
2182 }) else {
2183 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2184 };
2185 if evaluated != &expected
2186 || prediction
2187 .facets()
2188 .iter()
2189 .filter(|facet| {
2190 facet.state() == EnginePredictionFacetStateV1::Available
2191 && facet.scope() == &expected
2192 })
2193 .count()
2194 != 1
2195 || !is_exact_available_basis(facet.basis().references(), row)
2196 {
2197 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2198 }
2199 }
2200 }
2201 GltfAnimationCoverageStateV1::Partial | GltfAnimationCoverageStateV1::Unavailable => {
2202 let prediction = check
2203 .prediction
2204 .ok_or(GltfAnimationAddressabilityError::InvalidBevyCheckSubset)?;
2205 let facets = prediction.facets();
2206 let expected = EvaluationScope::new(
2207 animsmith_core::EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY,
2208 );
2209 if check.applicability != Applicability::Applicable
2210 || check.evaluation != EvaluationState::NotEvaluated
2211 || !check.evaluated_scopes.is_empty()
2212 || facets.len() != 1
2213 || facets[0].state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
2214 || facets[0].scope() != &expected
2215 || facets[0].reasons() != [PredictionUnavailableReasonV1::RawSourceIncomplete]
2216 || !is_exact_unavailable_basis(facets[0].basis().references())
2217 {
2218 return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2219 }
2220 }
2221 }
2222 Ok(())
2223}
2224
2225fn is_exact_available_basis(
2226 references: &[PredictionBasisReferenceV1],
2227 row: &GltfAnimationAddressabilityAnimationV1,
2228) -> bool {
2229 if references.len() != 3 {
2230 return false;
2231 }
2232 let expected_state = match row.source_name() {
2233 GltfAnimationObservationV1::Observed { .. } => "observed",
2234 GltfAnimationObservationV1::ProvenAbsent => "proven_absent",
2235 GltfAnimationObservationV1::Unavailable { .. } => "unavailable",
2236 };
2237 let mut profile_fact = false;
2238 let mut primary_source = false;
2239 let mut raw_source = false;
2240 for reference in references {
2241 match reference {
2242 PredictionBasisReferenceV1::ProfileFact { fact_id }
2243 if fact_id == "animation_addressability" =>
2244 {
2245 profile_fact = true;
2246 }
2247 PredictionBasisReferenceV1::PrimarySource { source_id }
2248 if source_id == BEVY_ANIMATION_LABEL_SOURCE =>
2249 {
2250 primary_source = true;
2251 }
2252 PredictionBasisReferenceV1::RawSource { reference }
2253 if reference.domain() == RawSourceDomainV1::Clip
2254 && reference.key()
2255 == &RawSourceKeyV1::Clip {
2256 source_clip_index: row.source_clip_index(),
2257 }
2258 && reference.field().as_str() == "source_name.state"
2259 && matches!(
2260 reference.value(),
2261 PredictionScalarV1::Token { value } if value == expected_state
2262 ) =>
2263 {
2264 raw_source = true;
2265 }
2266 _ => return false,
2267 }
2268 }
2269 profile_fact && primary_source && raw_source
2270}
2271
2272fn is_exact_unavailable_basis(references: &[PredictionBasisReferenceV1]) -> bool {
2273 if references.len() != 2 {
2274 return false;
2275 }
2276 let mut profile_fact = false;
2277 let mut primary_source = false;
2278 for reference in references {
2279 match reference {
2280 PredictionBasisReferenceV1::ProfileFact { fact_id }
2281 if fact_id == "animation_addressability" =>
2282 {
2283 profile_fact = true;
2284 }
2285 PredictionBasisReferenceV1::PrimarySource { source_id }
2286 if source_id == BEVY_ANIMATION_LABEL_SOURCE =>
2287 {
2288 primary_source = true;
2289 }
2290 _ => return false,
2291 }
2292 }
2293 profile_fact && primary_source
2294}
2295
2296fn validate_inventory_provenance_binding(
2297 provenance: &PredictionProvenanceV1,
2298 inventory: &GltfAnimationAddressabilityInventoryV1,
2299) -> Result<(), GltfAnimationAddressabilityError> {
2300 if provenance.source_format() != inventory.source_format
2301 || provenance.raw_source().primary_input() != inventory.primary_input()
2302 || provenance.dependency_closure() != inventory.dependency_closure()
2303 || raw_coverage_state(provenance.raw_source().clips_coverage().state())
2304 != inventory.animations.coverage.state
2305 || provenance
2306 .raw_source()
2307 .clips_coverage()
2308 .reason()
2309 .map(raw_unavailable_reason)
2310 != inventory.animations.coverage.reason
2311 {
2312 return Err(GltfAnimationAddressabilityError::BevyInventoryMismatch);
2313 }
2314 Ok(())
2315}
2316
2317fn validate_tool(tool: &ToolInputV1) -> Result<(), GltfAnimationAddressabilityError> {
2318 if tool.name != "animsmith"
2319 || !is_schema_semver(&tool.version)
2320 || tool.name.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES
2321 || tool.version.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES
2322 || matches!(tool.source.revision, RequiredNullable::Missing)
2323 || matches!(tool.source.dirty, RequiredNullable::Missing)
2324 || matches!(
2325 &tool.source.revision,
2326 RequiredNullable::Present(Some(revision))
2327 if revision.len() != 40
2328 || !revision.bytes().all(|value| value.is_ascii_hexdigit())
2329 )
2330 {
2331 return Err(GltfAnimationAddressabilityError::InvalidTool);
2332 }
2333 Ok(())
2334}
2335
2336fn is_schema_semver(version: &str) -> bool {
2337 let (without_build, build) = match version.split_once('+') {
2338 Some((value, build))
2339 if !build.is_empty()
2340 && !build.contains('+')
2341 && build
2342 .bytes()
2343 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2344 {
2345 (value, Some(build))
2346 }
2347 Some(_) => return false,
2348 None => (version, None),
2349 };
2350 let _ = build;
2351 let (core, prerelease) = match without_build.split_once('-') {
2352 Some((core, prerelease))
2353 if !prerelease.is_empty()
2354 && prerelease
2355 .bytes()
2356 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2357 {
2358 (core, Some(prerelease))
2359 }
2360 Some(_) => return false,
2361 None => (without_build, None),
2362 };
2363 let _ = prerelease;
2364 let mut components = core.split('.');
2365 (0..3).all(|_| {
2366 components.next().is_some_and(|value| {
2367 !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
2368 })
2369 }) && components.next().is_none()
2370}
2371
2372fn raw_coverage_state(value: RawSourceSetCoverageStateV1) -> GltfAnimationCoverageStateV1 {
2373 match value {
2374 RawSourceSetCoverageStateV1::Complete => GltfAnimationCoverageStateV1::Complete,
2375 RawSourceSetCoverageStateV1::Partial => GltfAnimationCoverageStateV1::Partial,
2376 RawSourceSetCoverageStateV1::Unavailable => GltfAnimationCoverageStateV1::Unavailable,
2377 }
2378}
2379
2380fn raw_unavailable_reason(value: RawSourceUnavailableReasonV1) -> GltfAnimationUnavailableReasonV1 {
2381 match value {
2382 RawSourceUnavailableReasonV1::Malformed => GltfAnimationUnavailableReasonV1::Malformed,
2383 RawSourceUnavailableReasonV1::Discarded => GltfAnimationUnavailableReasonV1::Discarded,
2384 RawSourceUnavailableReasonV1::NormalizedAway => {
2385 GltfAnimationUnavailableReasonV1::NormalizedAway
2386 }
2387 RawSourceUnavailableReasonV1::BakedAway => GltfAnimationUnavailableReasonV1::BakedAway,
2388 RawSourceUnavailableReasonV1::LoaderUnsupported => {
2389 GltfAnimationUnavailableReasonV1::LoaderUnsupported
2390 }
2391 RawSourceUnavailableReasonV1::ProjectionBudgetExceeded => {
2392 GltfAnimationUnavailableReasonV1::ProjectionBudgetExceeded
2393 }
2394 RawSourceUnavailableReasonV1::ParserUnavailable => {
2395 GltfAnimationUnavailableReasonV1::ParserUnavailable
2396 }
2397 }
2398}
2399
2400fn encode_animation_set(
2401 encoder: &mut CanonicalEncoder,
2402 animations: &GltfAnimationAddressabilityAnimationSetV1,
2403) {
2404 encoder.field("animations");
2405 encode_coverage(encoder, animations.coverage);
2406 encoder.count(animations.rows.len());
2407 for animation in &animations.rows {
2408 encoder.token(animation.source_clip_index.to_string());
2409 encode_observation(encoder, &animation.source_name, |encoder, value| {
2410 encoder.token(value)
2411 });
2412 encode_observation(
2413 encoder,
2414 &animation.normalized_clip_index,
2415 |encoder, value| encoder.token(value.to_string()),
2416 );
2417 encode_coverage(encoder, animation.channels.coverage);
2418 encoder.count(animation.channels.rows.len());
2419 for channel in &animation.channels.rows {
2420 encoder.token(channel.source_channel_index.to_string());
2421 encoder.token(target_kind_name(channel.target.kind));
2422 encoder.token(channel.target.index.to_string());
2423 encoder.token(property_name(channel.property));
2424 encoder.token(channel.input_accessor_index.to_string());
2425 encoder.token(channel.output_accessor_index.to_string());
2426 }
2427 }
2428}
2429
2430fn encode_coverage(encoder: &mut CanonicalEncoder, coverage: GltfAnimationCoverageV1) {
2431 encoder.token(match coverage.state {
2432 GltfAnimationCoverageStateV1::Complete => "complete",
2433 GltfAnimationCoverageStateV1::Partial => "partial",
2434 GltfAnimationCoverageStateV1::Unavailable => "unavailable",
2435 });
2436 encoder.token(
2437 coverage
2438 .reason
2439 .map(unavailable_reason_name)
2440 .unwrap_or("null"),
2441 );
2442}
2443
2444fn encode_observation<T>(
2445 encoder: &mut CanonicalEncoder,
2446 value: &GltfAnimationObservationV1<T>,
2447 encode_value: impl FnOnce(&mut CanonicalEncoder, &T),
2448) {
2449 match value {
2450 GltfAnimationObservationV1::Observed { value } => {
2451 encoder.token("observed");
2452 encode_value(encoder, value);
2453 }
2454 GltfAnimationObservationV1::ProvenAbsent => encoder.token("proven_absent"),
2455 GltfAnimationObservationV1::Unavailable { reason } => {
2456 encoder.token("unavailable");
2457 encoder.token(unavailable_reason_name(*reason));
2458 }
2459 }
2460}
2461
2462#[derive(Debug, Default)]
2463struct CanonicalEncoder(Vec<u8>);
2464
2465impl CanonicalEncoder {
2466 fn new(domain: &str) -> Self {
2467 let mut encoder = Self::default();
2468 encoder.token(domain);
2469 encoder
2470 }
2471
2472 fn token(&mut self, token: impl AsRef<str>) {
2473 let bytes = token.as_ref().as_bytes();
2474 self.0
2475 .extend_from_slice(&(bytes.len() as u64).to_be_bytes());
2476 self.0.extend_from_slice(bytes);
2477 }
2478
2479 fn field(&mut self, field: &'static str) {
2480 self.token(field);
2481 }
2482
2483 fn count(&mut self, count: usize) {
2484 self.token(count.to_string());
2485 }
2486
2487 fn identity(self) -> InputIdentity {
2488 InputIdentity::from_bytes(&self.0)
2489 }
2490}
2491
2492fn encode_input_identity(encoder: &mut CanonicalEncoder, identity: &InputIdentity) {
2493 encoder.token("sha256");
2494 encoder.token(identity.sha256());
2495 encoder.token("bytes");
2496 encoder.token(identity.bytes().to_string());
2497}
2498
2499fn source_format_name(value: SourceFormatV1) -> &'static str {
2500 match value {
2501 SourceFormatV1::GltfJson => "gltf_json",
2502 SourceFormatV1::Glb => "glb",
2503 SourceFormatV1::Fbx => "fbx",
2504 }
2505}
2506
2507fn unavailable_reason_name(value: GltfAnimationUnavailableReasonV1) -> &'static str {
2508 match value {
2509 GltfAnimationUnavailableReasonV1::Malformed => "malformed",
2510 GltfAnimationUnavailableReasonV1::Discarded => "discarded",
2511 GltfAnimationUnavailableReasonV1::NormalizedAway => "normalized_away",
2512 GltfAnimationUnavailableReasonV1::BakedAway => "baked_away",
2513 GltfAnimationUnavailableReasonV1::LoaderUnsupported => "loader_unsupported",
2514 GltfAnimationUnavailableReasonV1::ProjectionBudgetExceeded => "projection_budget_exceeded",
2515 GltfAnimationUnavailableReasonV1::ParserUnavailable => "parser_unavailable",
2516 }
2517}
2518
2519fn target_kind_name(value: GltfAnimationTargetKindV1) -> &'static str {
2520 match value {
2521 GltfAnimationTargetKindV1::Node => "node",
2522 GltfAnimationTargetKindV1::Element => "element",
2523 GltfAnimationTargetKindV1::Other => "other",
2524 }
2525}
2526
2527fn property_name(value: GltfAnimationChannelPropertyV1) -> &'static str {
2528 match value {
2529 GltfAnimationChannelPropertyV1::Translation => "translation",
2530 GltfAnimationChannelPropertyV1::Rotation => "rotation",
2531 GltfAnimationChannelPropertyV1::Scale => "scale",
2532 GltfAnimationChannelPropertyV1::Weights => "weights",
2533 GltfAnimationChannelPropertyV1::Other => "other",
2534 }
2535}
2536
2537#[cfg(test)]
2538mod tests {
2539 use super::*;
2540 use animsmith_core::{
2541 Check, CheckCtx, CheckOutput, CheckSelection, Clip, Config, Document, EngineFactValueV1,
2542 EnginePrimarySourceV1, EngineProfileFactV1, EngineProfileSelectionV1, MetricGrids,
2543 RawSourceBindingV1, RawSourceFactsBuilderV1, ResolvedEngineProfileV1,
2544 ResolvedEngineSettingsV1, SeveritySetting, SourceComponentMaskV1, SourceFactDomainV1,
2545 SourceFactSetV1, SourceLoaderDispositionV1, SourceObservationV1, SourceProvenanceV1,
2546 SourceTargetV1, SourceTextV1, ToolSource, evaluate_checks, resolve_configured_roles,
2547 };
2548 use std::collections::BTreeMap;
2549 use std::io::Cursor;
2550
2551 fn dummy_clip(index: usize) -> Clip {
2552 Clip {
2553 name: format!("clip-{index}"),
2554 duration_s: 0.0,
2555 tracks: Vec::new(),
2556 }
2557 }
2558
2559 fn raw_channel(
2560 index: usize,
2561 property: SourceChannelPropertyV1,
2562 ) -> animsmith_core::SourceChannelFactV1 {
2563 animsmith_core::SourceChannelFactV1::new(
2564 index,
2565 SourceTargetV1::new(SourceTargetKindV1::Node, index as u64),
2566 property,
2567 SourceComponentMaskV1::new(true, true, true),
2568 SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined()),
2569 SourceLoaderDispositionV1::Preserved,
2570 SourceProvenanceV1::format_defined(),
2571 )
2572 .with_accessors(index * 2, index * 2 + 1)
2573 }
2574
2575 fn raw_clip(
2576 index: usize,
2577 name: GltfAnimationObservationV1<String>,
2578 channels: Vec<animsmith_core::SourceChannelFactV1>,
2579 ) -> animsmith_core::SourceClipFactV1 {
2580 raw_clip_with_channel_set(index, name, SourceFactSetV1::complete(channels))
2581 }
2582
2583 fn raw_clip_with_channel_set(
2584 index: usize,
2585 name: GltfAnimationObservationV1<String>,
2586 channels: SourceFactSetV1<animsmith_core::SourceChannelFactV1>,
2587 ) -> animsmith_core::SourceClipFactV1 {
2588 let source_name = match name {
2589 GltfAnimationObservationV1::Observed { value } => SourceObservationV1::observed(
2590 SourceTextV1::new(value).expect("bounded test name"),
2591 SourceProvenanceV1::format_defined(),
2592 SourceLoaderDispositionV1::Preserved,
2593 ),
2594 GltfAnimationObservationV1::ProvenAbsent => {
2595 SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined())
2596 }
2597 GltfAnimationObservationV1::Unavailable { reason: _ } => {
2598 SourceObservationV1::unavailable(
2599 SourceUnavailableReasonV1::ParserUnavailable,
2600 None,
2601 SourceLoaderDispositionV1::Unknown,
2602 )
2603 }
2604 };
2605 animsmith_core::SourceClipFactV1::new(
2606 index,
2607 source_name,
2608 SourceObservationV1::observed(
2609 index,
2610 SourceProvenanceV1::format_defined(),
2611 SourceLoaderDispositionV1::Normalized,
2612 ),
2613 SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined()),
2614 SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined()),
2615 channels,
2616 )
2617 }
2618
2619 fn loaded_source(format: SourceFormatV1) -> LoadedSource {
2620 let primary = InputIdentity::from_bytes(b"analytic-gltf");
2621 let mut builder = RawSourceFactsBuilderV1::new(format, primary);
2622 assert!(builder.push_clip(raw_clip(
2623 0,
2624 GltfAnimationObservationV1::Observed {
2625 value: "walk".to_owned(),
2626 },
2627 vec![
2628 raw_channel(0, SourceChannelPropertyV1::Translation),
2629 raw_channel(1, SourceChannelPropertyV1::Rotation),
2630 raw_channel(2, SourceChannelPropertyV1::Scale),
2631 raw_channel(3, SourceChannelPropertyV1::Weights),
2632 ],
2633 )));
2634 assert!(builder.push_clip(raw_clip(
2635 1,
2636 GltfAnimationObservationV1::ProvenAbsent,
2637 Vec::new(),
2638 )));
2639 assert!(builder.push_clip(raw_clip(
2640 2,
2641 GltfAnimationObservationV1::Observed {
2642 value: "walk".to_owned(),
2643 },
2644 Vec::new(),
2645 )));
2646 builder.mark_complete(SourceFactDomainV1::Clips);
2647 let document = Document {
2648 clips: (0..3).map(dummy_clip).collect(),
2649 ..Document::default()
2650 };
2651 builder.finish(document).expect("valid same-load source")
2652 }
2653
2654 fn loaded_source_with_channel_set(
2655 channels: SourceFactSetV1<animsmith_core::SourceChannelFactV1>,
2656 ) -> LoadedSource {
2657 let primary = InputIdentity::from_bytes(b"analytic-channel-coverage");
2658 let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::GltfJson, primary);
2659 assert!(builder.push_clip(raw_clip_with_channel_set(
2660 0,
2661 GltfAnimationObservationV1::Observed {
2662 value: "walk".to_owned(),
2663 },
2664 channels,
2665 )));
2666 builder.mark_complete(SourceFactDomainV1::Clips);
2667 let document = Document {
2668 clips: vec![dummy_clip(0)],
2669 ..Document::default()
2670 };
2671 builder.finish(document).expect("valid same-load source")
2672 }
2673
2674 fn frozen_bevy_provenance(source: &LoadedSource) -> PredictionProvenanceV1 {
2675 let profile = crate::resolve_static(crate::EngineDeclaration {
2676 selection: Some(crate::ProfileSelection::new(
2677 BEVY_FAMILY,
2678 BEVY_PROFILE_REVISION,
2679 BEVY_ENGINE_VERSION,
2680 BEVY_IMPORTER,
2681 )),
2682 ..crate::EngineDeclaration::default()
2683 })
2684 .unwrap()
2685 .unwrap()
2686 .resolve_input(SourceFormatV1::GltfJson, &["walk".to_owned()])
2687 .unwrap();
2688 crate::project_prediction_provenance_v1(&profile, source).unwrap()
2689 }
2690
2691 fn direct_animation(index: usize, name: String) -> GltfAnimationAddressabilityAnimationV1 {
2692 GltfAnimationAddressabilityAnimationV1 {
2693 source_clip_index: index as u64,
2694 source_name: GltfAnimationObservationV1::Observed { value: name },
2695 normalized_clip_index: GltfAnimationObservationV1::Observed {
2696 value: index as u64,
2697 },
2698 channels: GltfAnimationAddressabilityChannelSetV1 {
2699 coverage: GltfAnimationCoverageV1 {
2700 state: GltfAnimationCoverageStateV1::Complete,
2701 reason: None,
2702 },
2703 rows: Vec::new(),
2704 },
2705 }
2706 }
2707
2708 fn direct_channel(index: usize) -> GltfAnimationAddressabilityChannelV1 {
2709 GltfAnimationAddressabilityChannelV1 {
2710 source_channel_index: index as u64,
2711 target: GltfAnimationTargetV1 {
2712 kind: GltfAnimationTargetKindV1::Node,
2713 index: 0,
2714 },
2715 property: GltfAnimationChannelPropertyV1::Translation,
2716 input_accessor_index: (index * 2) as u64,
2717 output_accessor_index: (index * 2 + 1) as u64,
2718 }
2719 }
2720
2721 fn direct_inventory(
2722 animations: Vec<GltfAnimationAddressabilityAnimationV1>,
2723 ) -> GltfAnimationAddressabilityInventoryV1 {
2724 let primary_input = InputIdentity::from_bytes(b"direct");
2725 direct_inventory_with_closure(
2726 primary_input.clone(),
2727 DependencyClosureV1::unavailable(primary_input),
2728 animations,
2729 )
2730 }
2731
2732 fn direct_inventory_with_closure(
2733 primary_input: InputIdentity,
2734 dependency_closure: DependencyClosureV1,
2735 animations: Vec<GltfAnimationAddressabilityAnimationV1>,
2736 ) -> GltfAnimationAddressabilityInventoryV1 {
2737 let mut value = GltfAnimationAddressabilityInventoryV1 {
2738 schema: GLTF_ANIMATION_ADDRESSABILITY_V1_ID,
2739 identity: GltfAnimationAddressabilityIdentityV1(InputIdentity::from_bytes(&[])),
2740 source_format: SourceFormatV1::GltfJson,
2741 primary_input: primary_input.clone(),
2742 dependency_closure,
2743 animations: GltfAnimationAddressabilityAnimationSetV1 {
2744 coverage: GltfAnimationCoverageV1 {
2745 state: GltfAnimationCoverageStateV1::Complete,
2746 reason: None,
2747 },
2748 rows: animations,
2749 },
2750 };
2751 value
2752 .validate_without_identity()
2753 .expect("valid direct inventory");
2754 value.identity = GltfAnimationAddressabilityIdentityV1(value.computed_identity());
2755 value
2756 }
2757
2758 fn bevy_provenance(source: &LoadedSource) -> PredictionProvenanceV1 {
2759 let all_fact_ids = [
2760 EngineFactIdV1::AcceptedInputs,
2761 EngineFactIdV1::AnimationAddressability,
2762 EngineFactIdV1::AnimationChannelHandling,
2763 EngineFactIdV1::AnimationTargetAddressability,
2764 EngineFactIdV1::AxisConversionControl,
2765 EngineFactIdV1::ConstructHandling,
2766 EngineFactIdV1::ExactAxisConversion,
2767 EngineFactIdV1::ExtensionHandling,
2768 EngineFactIdV1::ResultingHierarchyScale,
2769 EngineFactIdV1::RootMotionAddressability,
2770 EngineFactIdV1::TargetCoordinateBasis,
2771 EngineFactIdV1::TargetLinearUnit,
2772 EngineFactIdV1::UnitConversionControl,
2773 EngineFactIdV1::WholeEndFrameRequired,
2774 ];
2775 let facts = all_fact_ids
2776 .into_iter()
2777 .map(|id| {
2778 let state = match id {
2779 EngineFactIdV1::AcceptedInputs => {
2780 EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
2781 SourceFormatV1::GltfJson,
2782 SourceFormatV1::Glb,
2783 ]))
2784 }
2785 EngineFactIdV1::AnimationAddressability => {
2786 EngineFactStateV1::Known(EngineFactValueV1::AnimationAddressability(
2787 EngineAnimationAddressabilityV1::GltfAssetLabel,
2788 ))
2789 }
2790 _ => EngineFactStateV1::Unknown,
2791 };
2792 EngineProfileFactV1::new(id, state)
2793 })
2794 .collect();
2795 let profile = ResolvedEngineProfileV1::new(
2796 EngineProfileSelectionV1::new(
2797 BEVY_FAMILY,
2798 BEVY_PROFILE_REVISION,
2799 BEVY_ENGINE_VERSION,
2800 BEVY_IMPORTER,
2801 )
2802 .unwrap(),
2803 "urn:animsmith:engine-profile:bevy:test",
2804 facts,
2805 Vec::new(),
2806 vec![
2807 EnginePrimarySourceV1::new(
2808 BEVY_ANIMATION_LABEL_SOURCE,
2809 "test",
2810 "https://example.invalid/bevy",
2811 "2026-08-20",
2812 vec![
2813 EngineFactIdV1::AcceptedInputs,
2814 EngineFactIdV1::AnimationAddressability,
2815 ],
2816 Vec::new(),
2817 )
2818 .unwrap(),
2819 ],
2820 )
2821 .unwrap();
2822 let settings = ResolvedEngineSettingsV1::new(&profile, Vec::new(), Vec::new()).unwrap();
2823 PredictionProvenanceV1::new(
2824 profile,
2825 source.source_facts().format(),
2826 settings,
2827 RawSourceBindingV1::from_source(source.source_facts()),
2828 source.dependency_closure().clone(),
2829 )
2830 .unwrap()
2831 }
2832
2833 fn disabled_addressability_check(source: &LoadedSource) -> CheckEvaluation {
2834 struct Stub;
2835 impl Check for Stub {
2836 fn id(&self) -> &'static str {
2837 ENGINE_ADDRESSABILITY_CHECK_ID
2838 }
2839
2840 fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
2841 Applicability::Applicable
2842 }
2843
2844 fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
2845 panic!("disabled check must not evaluate")
2846 }
2847 }
2848
2849 let mut config = Config::default();
2850 config.checks = BTreeMap::from([(
2851 ENGINE_ADDRESSABILITY_CHECK_ID.to_owned(),
2852 animsmith_core::config::CheckSettings {
2853 severity: Some(SeveritySetting::Off),
2854 ..animsmith_core::config::CheckSettings::default()
2855 },
2856 )]);
2857 let roles = resolve_configured_roles(&source.document().skeleton, &config.rig);
2858 let grids = MetricGrids::new(source.document());
2859 let ctx = CheckCtx::new(&grids, &roles, &config);
2860 evaluate_checks(&ctx, &[Box::new(Stub)], CheckSelection::All)
2861 .unwrap()
2862 .pop()
2863 .unwrap()
2864 }
2865
2866 #[test]
2867 fn projection_preserves_source_identity_names_channels_and_accessors() {
2868 let source = loaded_source(SourceFormatV1::GltfJson);
2869 assert!(
2870 !source.dependency_closure().coverage().is_complete(),
2871 "the analytic source deliberately lacks complete dependency capture"
2872 );
2873 let inventory =
2874 GltfAnimationAddressabilityInventoryV1::from_source(&source).expect("glTF projection");
2875 assert_eq!(inventory.contract_id(), GLTF_ANIMATION_ADDRESSABILITY_V1_ID);
2876 assert_eq!(
2877 inventory.identity().input_identity().sha256(),
2878 "e6fa6c7e7cc8dbe5e61a8365f5c9c8a5e1c6343d3a5c0416eb1c2ea60cfba1b5"
2879 );
2880 assert_eq!(inventory.identity().input_identity().bytes(), 1_050);
2881 assert_eq!(
2882 inventory.primary_input(),
2883 source.source_facts().primary_identity()
2884 );
2885 assert_eq!(inventory.dependency_closure(), source.dependency_closure());
2886 assert_eq!(
2887 inventory.animations().coverage().state(),
2888 GltfAnimationCoverageStateV1::Complete
2889 );
2890 let rows = inventory.animations().rows();
2891 assert_eq!(rows.len(), 3);
2892 assert_eq!(rows[0].source_clip_index(), 0);
2893 assert_eq!(
2894 rows[0].source_name(),
2895 &GltfAnimationObservationV1::Observed {
2896 value: "walk".to_owned()
2897 }
2898 );
2899 assert_eq!(
2900 rows[1].source_name(),
2901 &GltfAnimationObservationV1::ProvenAbsent
2902 );
2903 assert_eq!(
2904 rows[2].source_name(),
2905 &GltfAnimationObservationV1::Observed {
2906 value: "walk".to_owned()
2907 }
2908 );
2909 let channels = rows[0].channels().rows();
2910 assert_eq!(channels.len(), 4);
2911 assert_eq!(
2912 channels[3].property(),
2913 GltfAnimationChannelPropertyV1::Weights
2914 );
2915 assert_eq!(channels[3].target().kind(), GltfAnimationTargetKindV1::Node);
2916 assert_eq!(channels[3].target().index(), 3);
2917 assert_eq!(channels[3].input_accessor_index(), 6);
2918 assert_eq!(channels[3].output_accessor_index(), 7);
2919 }
2920
2921 #[test]
2922 fn public_nested_readback_rejects_impossible_values() {
2923 assert!(
2924 serde_json::from_value::<GltfAnimationCoverageV1>(serde_json::json!({
2925 "state": "complete",
2926 "reason": "malformed"
2927 }))
2928 .is_err()
2929 );
2930 assert!(
2931 serde_json::from_value::<GltfAnimationCoverageV1>(serde_json::json!({
2932 "state": "complete",
2933 "reason": null
2934 }))
2935 .is_err()
2936 );
2937 assert!(
2938 serde_json::from_value::<GltfAnimationTargetV1>(serde_json::json!({
2939 "kind": "element",
2940 "index": 0
2941 }))
2942 .is_err()
2943 );
2944 assert!(
2945 serde_json::from_value::<GltfAnimationAddressabilityChannelV1>(serde_json::json!({
2946 "source_channel_index": 0,
2947 "target": {"kind": "node", "index": 0},
2948 "property": "translation",
2949 "input_accessor_index": 1,
2950 "output_accessor_index": null
2951 }))
2952 .is_err()
2953 );
2954 assert!(
2955 serde_json::from_value::<GltfAnimationAddressabilityChannelSetV1>(serde_json::json!({
2956 "coverage": {"state": "unavailable", "reason": "parser_unavailable"},
2957 "rows": [{
2958 "source_channel_index": 0,
2959 "target": {"kind": "node", "index": 0},
2960 "property": "translation",
2961 "input_accessor_index": 1,
2962 "output_accessor_index": 2
2963 }]
2964 }))
2965 .is_err()
2966 );
2967
2968 let channel_at_limit = serde_json::json!({
2969 "source_channel_index": animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 2,
2970 "target": {"kind": "node", "index": 0},
2971 "property": "translation",
2972 "input_accessor_index": 1,
2973 "output_accessor_index": 2
2974 });
2975 assert!(
2976 serde_json::from_value::<GltfAnimationAddressabilityChannelV1>(
2977 channel_at_limit.clone()
2978 )
2979 .is_ok()
2980 );
2981 let mut channel_over_limit = channel_at_limit;
2982 channel_over_limit["source_channel_index"] =
2983 serde_json::json!(animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1);
2984 assert!(
2985 serde_json::from_value::<GltfAnimationAddressabilityChannelV1>(channel_over_limit)
2986 .unwrap_err()
2987 .to_string()
2988 .contains("source channel index")
2989 );
2990
2991 let animation_at_limit = serde_json::json!({
2992 "source_clip_index": animsmith_core::RAW_SOURCE_V1_MAX_CLIPS - 1,
2993 "source_name": {"state": "proven_absent"},
2994 "normalized_clip_index": {"state": "proven_absent"},
2995 "channels": {
2996 "coverage": {"state": "complete"},
2997 "rows": []
2998 }
2999 });
3000 assert!(
3001 serde_json::from_value::<GltfAnimationAddressabilityAnimationV1>(
3002 animation_at_limit.clone()
3003 )
3004 .is_ok()
3005 );
3006 let mut animation_over_limit = animation_at_limit;
3007 animation_over_limit["source_clip_index"] =
3008 serde_json::json!(animsmith_core::RAW_SOURCE_V1_MAX_CLIPS);
3009 assert!(
3010 serde_json::from_value::<GltfAnimationAddressabilityAnimationV1>(animation_over_limit)
3011 .unwrap_err()
3012 .to_string()
3013 .contains("source clip index")
3014 );
3015 }
3016
3017 #[test]
3018 fn partial_and_unavailable_channel_coverage_round_trip_without_blocking_selectors() {
3019 let cases = [
3020 (
3021 SourceFactSetV1::partial(
3022 vec![raw_channel(0, SourceChannelPropertyV1::Translation)],
3023 SourceUnavailableReasonV1::ParserUnavailable,
3024 ),
3025 GltfAnimationCoverageStateV1::Partial,
3026 1,
3027 ),
3028 (
3029 SourceFactSetV1::unavailable(SourceUnavailableReasonV1::ParserUnavailable),
3030 GltfAnimationCoverageStateV1::Unavailable,
3031 0,
3032 ),
3033 ];
3034 let mut identities = Vec::new();
3035
3036 for (channels, expected_state, expected_rows) in cases {
3037 let source = loaded_source_with_channel_set(channels);
3038 let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3039 assert_eq!(
3040 inventory.animations().coverage().state(),
3041 GltfAnimationCoverageStateV1::Complete
3042 );
3043 let projected_channels = inventory.animations().rows()[0].channels();
3044 assert_eq!(projected_channels.coverage().state(), expected_state);
3045 assert!(projected_channels.coverage().reason().is_some());
3046 assert_eq!(projected_channels.rows().len(), expected_rows);
3047 identities.push(inventory.identity().clone());
3048
3049 let provenance = frozen_bevy_provenance(&source);
3050 let grids = MetricGrids::new(source.document());
3051 let roles = resolve_configured_roles(
3052 &source.document().skeleton,
3053 &animsmith_core::Config::default().rig,
3054 );
3055 let config = animsmith_core::Config::default();
3056 let adapter = crate::build_bevy_animation_addressability_adapter_v1(
3057 &source,
3058 &inventory,
3059 Some(provenance),
3060 &CheckCtx::new(&grids, &roles, &config),
3061 )
3062 .unwrap()
3063 .unwrap();
3064 let report = GltfAnimationAddressabilityV1::new(
3065 ToolInfo::animsmith(ToolSource::new(None, None)),
3066 inventory.clone(),
3067 Some(adapter),
3068 )
3069 .unwrap();
3070 let json = serde_json::to_vec(&report).unwrap();
3071 let readback = GltfAnimationAddressabilityInput::read_from(Cursor::new(json))
3072 .unwrap()
3073 .into_report()
3074 .unwrap();
3075 assert_eq!(readback.inventory(), &inventory);
3076 let check = readback.bevy().unwrap().check();
3077 assert_eq!(check.evaluation(), EvaluationState::Complete);
3078 let prediction = check.prediction().unwrap();
3079 assert_eq!(prediction.facets().len(), 1);
3080 assert_eq!(
3081 prediction.facets()[0].state(),
3082 EnginePredictionFacetStateV1::Available
3083 );
3084 assert_eq!(
3085 prediction.facets()[0].scope().subject.as_deref(),
3086 Some("Animation0")
3087 );
3088 }
3089
3090 assert_ne!(identities[0], identities[1]);
3091 }
3092
3093 #[test]
3094 fn strict_reader_rejects_noncanonical_collections_and_null_prediction() {
3095 let source = loaded_source(SourceFormatV1::GltfJson);
3096 let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3097 let provenance = frozen_bevy_provenance(&source);
3098 let adapter = GltfAnimationAddressabilityBevyAdapterV1::new(
3099 provenance,
3100 disabled_addressability_check(&source),
3101 &inventory,
3102 )
3103 .unwrap();
3104 let report = GltfAnimationAddressabilityV1::new(
3105 ToolInfo::animsmith(ToolSource::new(None, None)),
3106 inventory,
3107 Some(adapter),
3108 )
3109 .unwrap();
3110 let json = serde_json::to_value(report).unwrap();
3111 for field in ["findings", "gaps"] {
3112 let mut nonempty = json.clone();
3113 nonempty["bevy"]["check"][field] = serde_json::json!([null]);
3114 let bytes = serde_json::to_vec(&nonempty).unwrap();
3115 let input = GltfAnimationAddressabilityInput::read_from(Cursor::new(bytes)).unwrap();
3116 assert!(matches!(
3117 input.into_report(),
3118 Err(GltfAnimationAddressabilityError::InvalidBevyCheckShape { reason })
3119 if reason.contains("exact engine-addressability subset")
3120 ));
3121 }
3122
3123 let mut null_prediction = json;
3124 assert!(null_prediction["bevy"]["check"].get("prediction").is_none());
3125 null_prediction["bevy"]["check"]["prediction"] = serde_json::Value::Null;
3126 let bytes = serde_json::to_vec(&null_prediction).unwrap();
3127 let input = GltfAnimationAddressabilityInput::read_from(Cursor::new(bytes)).unwrap();
3128 assert!(matches!(
3129 input.into_report(),
3130 Err(GltfAnimationAddressabilityError::InvalidBevyCheckShape { .. })
3131 ));
3132 }
3133
3134 #[test]
3135 fn strict_reader_stops_at_the_prediction_file_basis_n_plus_one() {
3136 let source = loaded_source(SourceFormatV1::GltfJson);
3137 let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3138 let provenance = frozen_bevy_provenance(&source);
3139 let grids = MetricGrids::new(source.document());
3140 let roles = resolve_configured_roles(
3141 &source.document().skeleton,
3142 &animsmith_core::Config::default().rig,
3143 );
3144 let config = animsmith_core::Config::default();
3145 let adapter = crate::build_bevy_animation_addressability_adapter_v1(
3146 &source,
3147 &inventory,
3148 Some(provenance),
3149 &CheckCtx::new(&grids, &roles, &config),
3150 )
3151 .unwrap()
3152 .unwrap();
3153 let report = GltfAnimationAddressabilityV1::new(
3154 ToolInfo::animsmith(ToolSource::new(None, None)),
3155 inventory,
3156 Some(adapter),
3157 )
3158 .unwrap();
3159 let mut wire = serde_json::to_value(report).unwrap();
3160 let mut full_facet = wire["bevy"]["check"]["prediction"]["facets"][0].clone();
3161 let reference = full_facet["basis"]["references"][0].clone();
3162 full_facet["basis"]["references"] =
3163 vec![reference; animsmith_core::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET].into();
3164 let exact_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3165 / animsmith_core::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
3166 let mut facets = vec![full_facet.clone(); exact_count];
3167 full_facet["basis"]["references"] = serde_json::json!([null]);
3168 facets.push(full_facet);
3169 wire["bevy"]["check"]["prediction"]["facets"] = facets.into();
3170
3171 let input = GltfAnimationAddressabilityInput::read_from(Cursor::new(
3172 serde_json::to_vec(&wire).unwrap(),
3173 ))
3174 .unwrap();
3175 assert!(matches!(
3176 input.into_report(),
3177 Err(GltfAnimationAddressabilityError::InvalidBevyCheckShape { reason })
3178 if reason.contains("file basis-reference limit")
3179 ));
3180 }
3181
3182 #[test]
3183 fn projection_rejects_non_gltf_without_mutating_raw_facts() {
3184 let source = loaded_source(SourceFormatV1::Fbx);
3185 assert_eq!(
3186 GltfAnimationAddressabilityInventoryV1::from_source(&source),
3187 Err(GltfAnimationAddressabilityError::UnsupportedSourceFormat {
3188 format: SourceFormatV1::Fbx
3189 })
3190 );
3191 assert_eq!(source.source_facts().clips().rows().len(), 3);
3192 }
3193
3194 #[test]
3195 fn inventory_round_trip_is_strict_and_identity_bound() {
3196 let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&loaded_source(
3197 SourceFormatV1::Glb,
3198 ))
3199 .unwrap();
3200 let json = serde_json::to_value(&inventory).unwrap();
3201 let round_trip: GltfAnimationAddressabilityInventoryV1 =
3202 serde_json::from_value(json.clone()).unwrap();
3203 assert_eq!(round_trip, inventory);
3204
3205 let mut unknown = json.clone();
3206 unknown["unknown"] = serde_json::json!(true);
3207 assert!(serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(unknown).is_err());
3208 let mut unknown_animation = json.clone();
3209 unknown_animation["animations"]["rows"][0]["unknown"] = serde_json::json!(true);
3210 let error =
3211 serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(unknown_animation)
3212 .unwrap_err()
3213 .to_string();
3214 assert!(error.contains("unknown field `unknown`"), "{error}");
3215 let mut unknown_channel_set = json.clone();
3216 unknown_channel_set["animations"]["rows"][0]["channels"]["unknown"] =
3217 serde_json::json!(true);
3218 let error =
3219 serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(unknown_channel_set)
3220 .unwrap_err()
3221 .to_string();
3222 assert!(error.contains("unknown field `unknown`"), "{error}");
3223 let mut changed = json;
3224 changed["animations"]["rows"][0]["source_name"]["value"] = serde_json::json!("run");
3225 assert!(
3226 serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(changed)
3227 .unwrap_err()
3228 .to_string()
3229 .contains("canonical engine-neutral preimage")
3230 );
3231 }
3232
3233 #[test]
3234 fn gltf_rows_reject_non_node_targets_and_missing_accessors() {
3235 let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&loaded_source(
3236 SourceFormatV1::GltfJson,
3237 ))
3238 .unwrap();
3239 let json = serde_json::to_value(&inventory).unwrap();
3240
3241 let mut non_node = json.clone();
3242 non_node["animations"]["rows"][0]["channels"]["rows"][0]["target"]["kind"] =
3243 serde_json::json!("element");
3244 let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(non_node)
3245 .unwrap_err()
3246 .to_string();
3247 assert!(error.contains("target source nodes"), "{error}");
3248
3249 let mut missing_accessors = json;
3250 missing_accessors["animations"]["rows"][0]["channels"]["rows"][0]["input_accessor_index"] =
3251 serde_json::Value::Null;
3252 missing_accessors["animations"]["rows"][0]["channels"]["rows"][0]["output_accessor_index"] =
3253 serde_json::Value::Null;
3254 let error =
3255 serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(missing_accessors)
3256 .unwrap_err()
3257 .to_string();
3258 assert!(error.contains("expected u64"), "{error}");
3259 }
3260
3261 #[test]
3262 fn inventory_identity_changes_with_external_content_identity() {
3263 fn inventory(external: &[u8]) -> GltfAnimationAddressabilityInventoryV1 {
3264 let primary = InputIdentity::from_bytes(b"primary");
3265 let key = animsmith_core::DependencyResourceKeyV1::from_source_str(
3266 "buffer.bin",
3267 animsmith_core::ResourceKeySyntaxV1::GltfUri,
3268 )
3269 .unwrap();
3270 let mut builder = animsmith_core::DependencyClosureBuilderV1::new(
3271 primary.clone(),
3272 animsmith_core::SourceSetCoverageV1::complete(),
3273 1,
3274 );
3275 assert!(builder.begin_reference("buffer.bin".len(), 1));
3276 assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
3277 builder.record_external_open_attempt(&key).unwrap();
3278 assert!(
3279 builder
3280 .push_captured_external(
3281 0,
3282 animsmith_core::SourceResourceKindV1::Buffer,
3283 0,
3284 key,
3285 InputIdentity::from_bytes(external),
3286 )
3287 .unwrap()
3288 );
3289 direct_inventory_with_closure(primary, builder.finish().unwrap(), Vec::new())
3290 }
3291
3292 let before = inventory(b"before");
3293 let after = inventory(b"after");
3294 assert_ne!(before.dependency_closure(), after.dependency_closure());
3295 assert_ne!(before.identity(), after.identity());
3296 }
3297
3298 #[test]
3299 fn adapter_rejects_fabricated_profile_identity_and_coverage_binding_detects_mutation() {
3300 let source = loaded_source(SourceFormatV1::GltfJson);
3301 let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3302 let provenance = bevy_provenance(&source);
3303 let check = disabled_addressability_check(&source);
3304 assert_eq!(check.configuration(), ConfigurationState::Disabled);
3305 assert_ne!(
3306 provenance.profile().facts_identity().sha256(),
3307 BEVY_PROFILE_FACTS_SHA256
3308 );
3309 assert!(matches!(
3310 GltfAnimationAddressabilityBevyAdapterV1::new(provenance.clone(), check, &inventory,),
3311 Err(GltfAnimationAddressabilityError::InvalidBevyProfile)
3312 ));
3313
3314 let mut changed = inventory.clone();
3315 changed.animations.coverage = GltfAnimationCoverageV1 {
3316 state: GltfAnimationCoverageStateV1::Partial,
3317 reason: Some(GltfAnimationUnavailableReasonV1::ParserUnavailable),
3318 };
3319 changed.identity = GltfAnimationAddressabilityIdentityV1(changed.computed_identity());
3320 assert_eq!(
3321 validate_inventory_provenance_binding(&provenance, &changed),
3322 Err(GltfAnimationAddressabilityError::BevyInventoryMismatch)
3323 );
3324 }
3325
3326 #[test]
3327 fn animation_collection_accepts_n_and_rejects_n_plus_one() {
3328 let rows = (0..animsmith_core::RAW_SOURCE_V1_MAX_CLIPS)
3329 .map(|index| direct_animation(index, String::new()))
3330 .collect();
3331 let inventory = direct_inventory(rows);
3332 let mut json = serde_json::to_value(&inventory).unwrap();
3333 assert!(
3334 serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json.clone()).is_ok()
3335 );
3336 let rows = json["animations"]["rows"].as_array_mut().unwrap();
3337 rows.push(serde_json::Value::Null);
3338 let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json)
3339 .unwrap_err()
3340 .to_string();
3341 assert!(
3342 error.contains("4097 animations, exceeding the V1 limit of 4096"),
3343 "the N+1 sentinel must be rejected before its null shape is decoded: {error}"
3344 );
3345 }
3346
3347 #[test]
3348 fn channel_collection_accepts_n_and_rejects_n_plus_one() {
3349 let channels = (0..animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1)
3350 .map(direct_channel)
3351 .collect();
3352 let mut animation = direct_animation(0, String::new());
3353 animation.channels.rows = channels;
3354 let inventory = direct_inventory(vec![animation]);
3355 let mut json = serde_json::to_value(&inventory).unwrap();
3356 assert!(
3357 serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json.clone()).is_ok()
3358 );
3359 let rows = json["animations"]["rows"][0]["channels"]["rows"]
3360 .as_array_mut()
3361 .unwrap();
3362 rows.push(serde_json::Value::Null);
3363 let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json)
3364 .unwrap_err()
3365 .to_string();
3366 assert!(
3367 error.contains("65536 animation/channel rows, exceeding the V1 limit of 65535"),
3368 "the N+1 sentinel must be rejected before its null shape is decoded: {error}"
3369 );
3370 }
3371
3372 #[test]
3373 fn aggregate_row_budget_rejects_cross_animation_n_plus_one_before_decoding() {
3374 let first_channel_count = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 3;
3375 let mut first = direct_animation(0, String::new());
3376 first.channels.rows = (0..first_channel_count).map(direct_channel).collect();
3377 let mut second = direct_animation(1, String::new());
3378 second.channels.rows = vec![direct_channel(0)];
3379 let inventory = direct_inventory(vec![first, second]);
3380 let mut json = serde_json::to_value(&inventory).unwrap();
3381 assert!(
3382 serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json.clone()).is_ok()
3383 );
3384
3385 json["animations"]["rows"][1]["channels"]["rows"]
3386 .as_array_mut()
3387 .unwrap()
3388 .push(serde_json::Value::Null);
3389 let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json)
3390 .unwrap_err()
3391 .to_string();
3392 assert!(
3393 error.contains("65537 animation/channel rows, exceeding the V1 limit of 65536"),
3394 "the global N+1 sentinel must be rejected before its null shape is decoded: {error}"
3395 );
3396 }
3397
3398 #[test]
3399 fn evaluated_scope_collection_accepts_n_and_rejects_n_plus_one() {
3400 let scopes: Vec<_> = (0..animsmith_core::RAW_SOURCE_V1_MAX_CLIPS)
3401 .map(|index| {
3402 serde_json::json!({
3403 "code": "animation_asset_label",
3404 "subject": format!("Animation{index}"),
3405 })
3406 })
3407 .collect();
3408 let mut check = serde_json::json!({
3409 "check_id": ENGINE_ADDRESSABILITY_CHECK_ID,
3410 "selection": "selected",
3411 "configuration": "enabled",
3412 "applicability": "applicable",
3413 "evaluation": "complete",
3414 "findings": [],
3415 "evaluated_scopes": scopes,
3416 "gaps": [],
3417 });
3418 let wire: GltfAnimationAddressabilityCheckWireV1 =
3419 serde_json::from_value(check.clone()).unwrap();
3420 assert_eq!(
3421 wire.evaluated_scopes.len(),
3422 animsmith_core::RAW_SOURCE_V1_MAX_CLIPS
3423 );
3424
3425 check["evaluated_scopes"]
3426 .as_array_mut()
3427 .unwrap()
3428 .push(serde_json::Value::Null);
3429 let error = serde_json::from_value::<GltfAnimationAddressabilityCheckWireV1>(check)
3430 .unwrap_err()
3431 .to_string();
3432 assert!(
3433 error.contains("exact engine-addressability subset"),
3434 "the N+1 sentinel must be rejected before its null shape is decoded: {error}"
3435 );
3436 }
3437
3438 #[test]
3439 fn embedded_check_rejects_first_finding_or_gap_without_materializing_it() {
3440 for field in ["findings", "gaps"] {
3441 let mut check = serde_json::json!({
3442 "check_id": ENGINE_ADDRESSABILITY_CHECK_ID,
3443 "selection": "selected",
3444 "configuration": "enabled",
3445 "applicability": "applicable",
3446 "evaluation": "complete",
3447 "findings": [],
3448 "evaluated_scopes": [],
3449 "gaps": [],
3450 });
3451 check[field] = serde_json::json!([null]);
3452 let error = serde_json::from_value::<GltfAnimationAddressabilityCheckWireV1>(check)
3453 .unwrap_err()
3454 .to_string();
3455 assert!(
3456 error.contains("exact engine-addressability subset"),
3457 "{field}'s first sentinel must be rejected before Box<RawValue> decoding: {error}"
3458 );
3459 }
3460 }
3461
3462 #[test]
3463 fn per_text_and_aggregate_text_bounds_accept_n_and_reject_n_plus_one() {
3464 let at_per_text_limit = direct_inventory(vec![direct_animation(
3465 0,
3466 "x".repeat(animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES),
3467 )]);
3468 let mut too_long = serde_json::to_value(&at_per_text_limit).unwrap();
3469 too_long["animations"]["rows"][0]["source_name"]["value"] =
3470 serde_json::json!("x".repeat(animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES + 1));
3471 let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(too_long)
3472 .unwrap_err()
3473 .to_string();
3474 assert!(error.contains("source name"), "{error}");
3475
3476 let full_names = animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES
3477 / animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES;
3478 let at_aggregate_limit = direct_inventory(
3479 (0..full_names)
3480 .map(|index| {
3481 direct_animation(
3482 index,
3483 "x".repeat(animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES),
3484 )
3485 })
3486 .collect(),
3487 );
3488 let mut over = at_aggregate_limit.animations.rows.clone();
3489 over.push(direct_animation(over.len(), "x".to_owned()));
3490 let mut invalid = GltfAnimationAddressabilityInventoryV1 {
3491 animations: GltfAnimationAddressabilityAnimationSetV1 {
3492 coverage: at_aggregate_limit.animations.coverage,
3493 rows: over,
3494 },
3495 ..at_aggregate_limit
3496 };
3497 assert_eq!(
3498 invalid.validate_without_identity(),
3499 Err(GltfAnimationAddressabilityError::TooMuchText {
3500 found: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES + 1,
3501 limit: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES,
3502 })
3503 );
3504 invalid.animations.rows.pop();
3505 assert!(invalid.validate_without_identity().is_ok());
3506 }
3507
3508 #[test]
3509 fn standalone_null_adapter_round_trips_and_cross_contracts_are_rejected() {
3510 let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&loaded_source(
3511 SourceFormatV1::GltfJson,
3512 ))
3513 .unwrap();
3514 let report = GltfAnimationAddressabilityV1::new(
3515 ToolInfo::animsmith(ToolSource::new(None, None)),
3516 inventory.clone(),
3517 None,
3518 )
3519 .unwrap();
3520 let json = serde_json::to_vec(&report).unwrap();
3521 assert!(crate::validate_bevy_readback_v1(json.as_slice()).is_err());
3522 let read = GltfAnimationAddressabilityInput::read_from(Cursor::new(&json))
3523 .unwrap()
3524 .into_report()
3525 .unwrap();
3526 assert_eq!(read.inventory(), &inventory);
3527 assert!(read.bevy().is_none());
3528
3529 let output_v10 = serde_json::json!({
3530 "schema_version": 10,
3531 "schema": animsmith_core::OUTPUT_SCHEMA_ID,
3532 "tool": {"name":"animsmith","version":"0.3.1","source":{"revision":null,"dirty":null}},
3533 "command": "measure",
3534 "summary": {"files":0},
3535 "files": []
3536 });
3537 assert!(serde_json::from_value::<GltfAnimationAddressabilityInput>(output_v10).is_err());
3538 assert!(
3539 serde_json::from_slice::<animsmith_core::MeasurementReportInput>(&json)
3540 .unwrap()
3541 .into_files()
3542 .is_err()
3543 );
3544
3545 let mut invalid_tool = serde_json::to_value(&report).unwrap();
3546 invalid_tool["tool"]["name"] = serde_json::json!("other");
3547 let input: GltfAnimationAddressabilityInput = serde_json::from_value(invalid_tool).unwrap();
3548 assert!(matches!(
3549 input.into_report(),
3550 Err(GltfAnimationAddressabilityError::InvalidTool)
3551 ));
3552
3553 let mut other_version = serde_json::to_value(&report).unwrap();
3554 other_version["tool"]["version"] = serde_json::json!("9.9.9-beta.1+pipeline");
3555 let input: GltfAnimationAddressabilityInput =
3556 serde_json::from_value(other_version).unwrap();
3557 assert!(
3558 input.into_report().is_ok(),
3559 "immutable V1 readback must not be coupled to the reader package version"
3560 );
3561
3562 for invalid_version in ["", "1", "1.2", "1.2.3.4", "1.2.3+", "1.2.3 bad"] {
3563 let mut invalid = serde_json::to_value(&report).unwrap();
3564 invalid["tool"]["version"] = serde_json::json!(invalid_version);
3565 let input: GltfAnimationAddressabilityInput = serde_json::from_value(invalid).unwrap();
3566 assert!(matches!(
3567 input.into_report(),
3568 Err(GltfAnimationAddressabilityError::InvalidTool)
3569 ));
3570 }
3571
3572 let mut missing_tool_field = serde_json::to_value(&report).unwrap();
3573 missing_tool_field["tool"]["source"]
3574 .as_object_mut()
3575 .unwrap()
3576 .remove("revision");
3577 let input: GltfAnimationAddressabilityInput =
3578 serde_json::from_value(missing_tool_field).unwrap();
3579 assert!(matches!(
3580 input.into_report(),
3581 Err(GltfAnimationAddressabilityError::InvalidTool)
3582 ));
3583 }
3584
3585 #[test]
3586 fn staged_reader_accepts_byte_n_and_rejects_n_plus_one() {
3587 let exact = GltfAnimationAddressabilityInput::read_from_with_limit(Cursor::new(b"null"), 4);
3588 assert!(matches!(
3589 exact,
3590 Err(GltfAnimationAddressabilityReadError::InvalidJson { .. })
3591 ));
3592 let over = GltfAnimationAddressabilityInput::read_from_with_limit(Cursor::new(b"null "), 4);
3593 assert!(matches!(
3594 over,
3595 Err(GltfAnimationAddressabilityReadError::ReportTooLarge { limit: 4 })
3596 ));
3597 }
3598}