1use crate::{
7 GltfAddressabilityNamedMapKindV2, GltfAddressabilityProjectionV2, GltfAddressabilityReadbackV2,
8 GltfAnimationCoverageStateV1,
9};
10use animsmith_core::InputIdentity;
11use serde::{Deserialize, Serialize};
12use std::io::{self, Read, Write};
13
14pub const BEVY_READBACK_V1_ID: &str = "urn:animsmith:schema:bevy-readback:1";
16pub const BEVY_READBACK_V1_SCHEMA_VERSION: u32 = 1;
18pub const BEVY_READBACK_V1_MAX_REPORT_BYTES: u64 = 16 * 1024 * 1024;
20pub const BEVY_READBACK_V1_MAX_ROWS: usize = 4_096;
22pub const BEVY_READBACK_V1_MAX_WORK: usize = 65_536;
24pub const BEVY_READBACK_V1_MAX_UPDATES: u64 = 4_096;
26pub const BEVY_READBACK_V1_RUSTC: &str = "rustc 1.95.0 (59807616e 2026-04-14)";
28pub const BEVY_READBACK_V1_LOCK_BYTES: u64 = 86_390;
30pub const BEVY_READBACK_V1_LOCK_SHA256: &str =
32 "e3457e21695874f90110e5b93a36d7688c134a3b9259cb1dc4d2d8e5f741d1ed";
33const MAX_TEXT_BYTES: usize = 1_024;
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct BevyPredictionReferenceV1 {
39 prediction_input: InputIdentity,
40 provenance_schema: String,
41 provenance_identity: InputIdentity,
42}
43impl BevyPredictionReferenceV1 {
44 pub fn new(
46 prediction_input: InputIdentity,
47 provenance_schema: String,
48 provenance_identity: InputIdentity,
49 ) -> Self {
50 Self {
51 prediction_input,
52 provenance_schema,
53 provenance_identity,
54 }
55 }
56 pub const fn prediction_input(&self) -> &InputIdentity {
58 &self.prediction_input
59 }
60 pub fn provenance_schema(&self) -> &str {
62 &self.provenance_schema
63 }
64 pub const fn provenance_identity(&self) -> &InputIdentity {
66 &self.provenance_identity
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct BevyHarnessIdentityV1 {
74 engine: String,
75 engine_version: String,
76 tool_version: String,
77 rust_toolchain: String,
78 bevy_animation_feature: bool,
79 load_animations: bool,
80 lock_identity: InputIdentity,
81 updates: u64,
82}
83impl BevyHarnessIdentityV1 {
84 pub fn new(
86 tool_version: String,
87 rust_toolchain: String,
88 bevy_animation_feature: bool,
89 load_animations: bool,
90 lock_identity: InputIdentity,
91 updates: u64,
92 ) -> Self {
93 Self {
94 engine: "bevy".into(),
95 engine_version: "0.19.0".into(),
96 tool_version,
97 rust_toolchain,
98 bevy_animation_feature,
99 load_animations,
100 lock_identity,
101 updates,
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct BevyIndexedLabelV1 {
110 index: u32,
111 label: String,
112}
113impl BevyIndexedLabelV1 {
114 pub fn new(index: u32, label: String) -> Self {
116 Self { index, label }
117 }
118 pub const fn index(&self) -> u32 {
120 self.index
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct BevyNamedWinnerV1 {
128 name: String,
129 index: u32,
130}
131impl BevyNamedWinnerV1 {
132 pub fn new(name: String, index: u32) -> Self {
134 Self { name, index }
135 }
136 pub fn sort_key(&self) -> (&str, u32) {
138 (&self.name, self.index)
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct BevyAnimationTargetV1 {
146 animation_index: u32,
147 target_id: String,
148}
149impl BevyAnimationTargetV1 {
150 pub fn new(animation_index: u32, target_id: String) -> Self {
152 Self {
153 animation_index,
154 target_id,
155 }
156 }
157 pub fn sort_key(&self) -> (u32, &str) {
159 (self.animation_index, &self.target_id)
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum BevyLoadErrorCodeV1 {
167 EmptyPath,
169 RequestedHandleTypeMismatch,
171 MissingAssetLoader,
173 MissingAssetLoaderForExtension,
175 MissingAssetLoaderForTypeName,
177 MissingAssetLoaderForTypeId,
179 AssetReader,
181 MissingAssetSource,
183 MissingProcessedAssetReader,
185 AssetMetadata,
187 DeserializeMetadata,
189 CannotLoadProcessedAsset,
191 CannotLoadIgnoredAsset,
193 LoaderPanic,
195 Loader,
197 AddAsync,
199 MissingLabel,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
206pub enum BevyTerminalStateV1 {
207 Loaded,
209 RootFailure {
211 error: BevyLoadErrorCodeV1,
213 },
214 DependencyFailure {
216 error: BevyLoadErrorCodeV1,
218 },
219 WorkLimit,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct BevyWarningV1 {
227 target: String,
228 level: String,
229}
230impl BevyWarningV1 {
231 pub fn new(target: String, level: String) -> Self {
233 Self { target, level }
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct BevyObservationV1 {
241 terminal: BevyTerminalStateV1,
242 animations: Vec<BevyIndexedLabelV1>,
243 named_animation_winners: Vec<BevyNamedWinnerV1>,
244 named_scene_winners: Vec<BevyNamedWinnerV1>,
245 named_skin_winners: Vec<BevyNamedWinnerV1>,
246 default_scene: Option<u32>,
247 scenes: Vec<BevyIndexedLabelV1>,
248 nodes: Vec<BevyIndexedLabelV1>,
249 skins: Vec<BevyIndexedLabelV1>,
250 inverse_bind_matrices: Vec<BevyIndexedLabelV1>,
251 targets: Vec<BevyAnimationTargetV1>,
252 warnings: Vec<BevyWarningV1>,
253 warnings_truncated: bool,
254 primary_verified: bool,
255 dependencies_verified: bool,
256}
257impl BevyObservationV1 {
258 #[allow(clippy::too_many_arguments)]
264 pub fn new(
265 terminal: BevyTerminalStateV1,
266 animations: Vec<BevyIndexedLabelV1>,
267 named_animation_winners: Vec<BevyNamedWinnerV1>,
268 named_scene_winners: Vec<BevyNamedWinnerV1>,
269 named_skin_winners: Vec<BevyNamedWinnerV1>,
270 default_scene: Option<u32>,
271 scenes: Vec<BevyIndexedLabelV1>,
272 nodes: Vec<BevyIndexedLabelV1>,
273 skins: Vec<BevyIndexedLabelV1>,
274 inverse_bind_matrices: Vec<BevyIndexedLabelV1>,
275 targets: Vec<BevyAnimationTargetV1>,
276 warnings: Vec<BevyWarningV1>,
277 warnings_truncated: bool,
278 primary_verified: bool,
279 dependencies_verified: bool,
280 ) -> Self {
281 Self {
282 terminal,
283 animations,
284 named_animation_winners,
285 named_scene_winners,
286 named_skin_winners,
287 default_scene,
288 scenes,
289 nodes,
290 skins,
291 inverse_bind_matrices,
292 targets,
293 warnings,
294 warnings_truncated,
295 primary_verified,
296 dependencies_verified,
297 }
298 }
299 pub const fn warnings_truncated(&self) -> bool {
301 self.warnings_truncated
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum BevyConformanceCodeV1 {
309 InputIdentityMismatch,
311 PredictionDocumentMismatch,
313 BevyAdapterUnavailable,
315 ProvenanceMismatch,
317 DependencyIdentityMismatch,
319 RequiredPredictionUnavailable,
321 LoadDidNotSucceed,
323 SettingsMismatch,
325 InventoryMismatch,
327 SceneMismatch,
329 DefaultSceneMismatch,
331 SkinMismatch,
333 NamedWinnerMismatch,
335 TargetMismatch,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
342pub enum BevyConformanceV1 {
343 Exact,
345 NotExact {
347 mismatch_codes: Vec<BevyConformanceCodeV1>,
349 unavailable_codes: Vec<BevyConformanceCodeV1>,
351 },
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct BevyReadbackV1 {
358 schema_version: u32,
359 schema: String,
360 identity: InputIdentity,
361 harness: BevyHarnessIdentityV1,
362 input: InputIdentity,
363 prediction: BevyPredictionReferenceV1,
364 observation: BevyObservationV1,
365 conformance: BevyConformanceV1,
366}
367impl BevyReadbackV1 {
368 pub fn new(
370 harness: BevyHarnessIdentityV1,
371 input: InputIdentity,
372 prediction: BevyPredictionReferenceV1,
373 observation: BevyObservationV1,
374 conformance: BevyConformanceV1,
375 ) -> Result<Self, BevyReadbackV1Error> {
376 let mut value = Self {
377 schema_version: 1,
378 schema: BEVY_READBACK_V1_ID.into(),
379 identity: InputIdentity::from_bytes(&[]),
380 harness,
381 input,
382 prediction,
383 observation,
384 conformance,
385 };
386 value.identity = value.computed_identity()?;
387 value.validate()?;
388 Ok(value)
389 }
390 pub const fn input(&self) -> &InputIdentity {
392 &self.input
393 }
394 pub const fn observation(&self) -> &BevyObservationV1 {
396 &self.observation
397 }
398 pub const fn conformance(&self) -> &BevyConformanceV1 {
400 &self.conformance
401 }
402 fn computed_identity(&self) -> Result<InputIdentity, BevyReadbackV1Error> {
403 bounded_jcs(&Seed {
404 schema_version: self.schema_version,
405 schema: &self.schema,
406 harness: &self.harness,
407 input: &self.input,
408 prediction: &self.prediction,
409 observation: &self.observation,
410 conformance: &self.conformance,
411 })
412 .map(|bytes| InputIdentity::from_bytes(&bytes))
413 }
414 pub fn validate(&self) -> Result<(), BevyReadbackV1Error> {
416 if self.schema_version != 1 || self.schema != BEVY_READBACK_V1_ID {
417 return Err(BevyReadbackV1Error::Contract("invalid schema header"));
418 }
419 let h = &self.harness;
420 if h.engine != "bevy"
421 || h.engine_version != "0.19.0"
422 || !safe(&h.tool_version)
423 || h.rust_toolchain != BEVY_READBACK_V1_RUSTC
424 || !h.bevy_animation_feature
425 || !h.load_animations
426 || h.lock_identity.bytes() != BEVY_READBACK_V1_LOCK_BYTES
427 || h.lock_identity.sha256() != BEVY_READBACK_V1_LOCK_SHA256
428 || h.updates > BEVY_READBACK_V1_MAX_UPDATES
429 {
430 return Err(BevyReadbackV1Error::Contract(
431 "invalid frozen harness tuple",
432 ));
433 }
434 if self.prediction.provenance_schema != animsmith_core::PREDICTION_PROVENANCE_V4_ID {
435 return Err(BevyReadbackV1Error::Contract(
436 "invalid V4 provenance header",
437 ));
438 }
439 validate_observation(&self.observation)?;
440 validate_conformance(&self.conformance)?;
441 if matches!(self.conformance, BevyConformanceV1::Exact)
442 && (!matches!(self.observation.terminal, BevyTerminalStateV1::Loaded)
443 || !self.observation.primary_verified
444 || !self.observation.dependencies_verified)
445 {
446 return Err(BevyReadbackV1Error::Contract(
447 "exact conformance requires a verified loaded observation",
448 ));
449 }
450 if self.identity != self.computed_identity()? {
451 return Err(BevyReadbackV1Error::Contract("self identity mismatch"));
452 }
453 bounded_jcs(self)?;
454 Ok(())
455 }
456}
457#[derive(Serialize)]
458struct Seed<'a> {
459 schema_version: u32,
460 schema: &'a str,
461 harness: &'a BevyHarnessIdentityV1,
462 input: &'a InputIdentity,
463 prediction: &'a BevyPredictionReferenceV1,
464 observation: &'a BevyObservationV1,
465 conformance: &'a BevyConformanceV1,
466}
467
468fn bounded_jcs(value: &impl Serialize) -> Result<Vec<u8>, BevyReadbackV1Error> {
469 bounded_jcs_with_limit(value, BEVY_READBACK_V1_MAX_REPORT_BYTES as usize)
470}
471
472fn bounded_jcs_with_limit(
473 value: &impl Serialize,
474 limit: usize,
475) -> Result<Vec<u8>, BevyReadbackV1Error> {
476 let mut writer = BoundedJcsWriter {
477 bytes: Vec::new(),
478 limit,
479 overflowed: false,
480 };
481 serde_jcs::to_writer(&mut writer, value).map_err(|error| {
482 if writer.overflowed {
483 BevyReadbackV1Error::CanonicalTooLarge {
484 limit: limit as u64,
485 }
486 } else {
487 BevyReadbackV1Error::Canonical(error)
488 }
489 })?;
490 Ok(writer.bytes)
491}
492
493struct BoundedJcsWriter {
494 bytes: Vec<u8>,
495 limit: usize,
496 overflowed: bool,
497}
498
499impl Write for BoundedJcsWriter {
500 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
501 let Some(length) = self.bytes.len().checked_add(bytes.len()) else {
502 self.overflowed = true;
503 return Err(io::Error::other("Bevy readback canonical limit"));
504 };
505 if length > self.limit {
506 self.overflowed = true;
507 return Err(io::Error::other("Bevy readback canonical limit"));
508 }
509 self.bytes.extend_from_slice(bytes);
510 Ok(bytes.len())
511 }
512
513 fn flush(&mut self) -> io::Result<()> {
514 Ok(())
515 }
516}
517
518#[derive(Debug, thiserror::Error)]
520#[non_exhaustive]
521pub enum BevyReadbackV1Error {
522 #[error("cannot read Bevy readback: {0}")]
524 Io(#[from] std::io::Error),
525 #[error("Bevy readback exceeds byte limit {limit}")]
527 ReportTooLarge {
528 limit: u64,
530 },
531 #[error("invalid Bevy readback JSON: {0}")]
533 Json(#[from] serde_json::Error),
534 #[error("cannot canonicalize Bevy readback: {0}")]
536 Canonical(serde_json::Error),
537 #[error("Bevy readback canonical bytes exceed byte limit {limit}")]
539 CanonicalTooLarge {
540 limit: u64,
542 },
543 #[error("invalid Bevy readback V1 contract: {0}")]
545 Contract(&'static str),
546}
547
548pub fn validate_bevy_readback_v1(reader: impl Read) -> Result<BevyReadbackV1, BevyReadbackV1Error> {
550 let mut bytes = Vec::new();
551 reader
552 .take(BEVY_READBACK_V1_MAX_REPORT_BYTES + 1)
553 .read_to_end(&mut bytes)?;
554 if bytes.len() as u64 > BEVY_READBACK_V1_MAX_REPORT_BYTES {
555 return Err(BevyReadbackV1Error::ReportTooLarge {
556 limit: BEVY_READBACK_V1_MAX_REPORT_BYTES,
557 });
558 }
559 let value: BevyReadbackV1 = serde_json::from_slice(&bytes)?;
560 value.validate()?;
561 Ok(value)
562}
563
564pub fn validate_bevy_readback_prediction_v1(
566 readback: &BevyReadbackV1,
567 prediction: &GltfAddressabilityReadbackV2,
568 prediction_input: &InputIdentity,
569) -> Result<(), BevyReadbackV1Error> {
570 readback.validate()?;
571 if compare_bevy_readback_v1(readback, prediction, prediction_input) != readback.conformance {
572 return Err(BevyReadbackV1Error::Contract(
573 "stored conformance differs from recomputation",
574 ));
575 }
576 Ok(())
577}
578
579pub fn compare_bevy_readback_v1(
581 readback: &BevyReadbackV1,
582 prediction: &GltfAddressabilityReadbackV2,
583 prediction_input: &InputIdentity,
584) -> BevyConformanceV1 {
585 let mut mismatch = Vec::new();
586 let mut unavailable = Vec::new();
587 if readback.input != *prediction.input() || !readback.observation.primary_verified {
588 mismatch.push(BevyConformanceCodeV1::InputIdentityMismatch);
589 }
590 if readback.prediction.prediction_input != *prediction_input {
591 mismatch.push(BevyConformanceCodeV1::PredictionDocumentMismatch);
592 }
593 let Some(adapter) = prediction.bevy() else {
594 return BevyConformanceV1::NotExact {
595 mismatch_codes: vec![BevyConformanceCodeV1::BevyAdapterUnavailable],
596 unavailable_codes: Vec::new(),
597 };
598 };
599 let provenance = adapter.prediction_provenance();
600 if readback.prediction.provenance_schema != provenance.contract_id()
601 || readback.prediction.provenance_identity != *provenance.identity().input_identity()
602 {
603 mismatch.push(BevyConformanceCodeV1::ProvenanceMismatch);
604 }
605 if !readback.observation.dependencies_verified {
606 mismatch.push(BevyConformanceCodeV1::DependencyIdentityMismatch);
607 }
608 if !matches!(readback.observation.terminal, BevyTerminalStateV1::Loaded) {
609 mismatch.push(BevyConformanceCodeV1::LoadDidNotSucceed);
610 }
611 let projection = adapter.projection();
612 if readback.harness.bevy_animation_feature != adapter.settings().bevy_animation_feature()
613 || readback.harness.load_animations != adapter.settings().load_animations()
614 {
615 mismatch.push(BevyConformanceCodeV1::SettingsMismatch);
616 }
617 let animations_available = prediction
618 .inventory()
619 .animations()
620 .animations()
621 .coverage()
622 .state()
623 == GltfAnimationCoverageStateV1::Complete;
624 let expected_animations = prediction
625 .inventory()
626 .animations()
627 .animations()
628 .rows()
629 .iter()
630 .map(|row| {
631 BevyIndexedLabelV1::new(
632 row.source_clip_index() as u32,
633 format!("Animation{}", row.source_clip_index()),
634 )
635 })
636 .collect::<Vec<_>>();
637 compare_available_prediction_facet(
638 animations_available.then_some(expected_animations),
639 &readback.observation.animations,
640 BevyConformanceCodeV1::InventoryMismatch,
641 &mut mismatch,
642 &mut unavailable,
643 );
644 let raw = prediction.inventory().raw();
645 if raw.scene_coverage().is_complete() {
646 let expected = raw
647 .scenes()
648 .iter()
649 .map(|row| {
650 BevyIndexedLabelV1::new(
651 row.source_scene_index() as u32,
652 format!("Scene{}", row.source_scene_index()),
653 )
654 })
655 .collect::<Vec<_>>();
656 if expected != readback.observation.scenes {
657 mismatch.push(BevyConformanceCodeV1::SceneMismatch);
658 }
659 } else {
660 unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable);
661 }
662 if raw.node_coverage().is_complete() {
663 let expected = raw
664 .nodes()
665 .iter()
666 .map(|row| {
667 BevyIndexedLabelV1::new(
668 row.source_node_index() as u32,
669 format!("Node{}", row.source_node_index()),
670 )
671 })
672 .collect::<Vec<_>>();
673 if expected != readback.observation.nodes {
674 mismatch.push(BevyConformanceCodeV1::InventoryMismatch);
675 }
676 } else {
677 unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable);
678 }
679 let mut expected_skins = Vec::new();
680 let mut skins_available = true;
681 for skin in projection.skins() {
682 match skin.skin_label() {
683 GltfAddressabilityProjectionV2::Available { value } => {
684 expected_skins.push(BevyIndexedLabelV1::new(
685 skin.source_skin_index() as u32,
686 value.clone(),
687 ));
688 }
689 GltfAddressabilityProjectionV2::ProvenAbsent => {}
690 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
691 skins_available = false;
692 }
693 }
694 }
695 compare_available_prediction_facet(
696 skins_available.then_some(expected_skins),
697 &readback.observation.skins,
698 BevyConformanceCodeV1::SkinMismatch,
699 &mut mismatch,
700 &mut unavailable,
701 );
702 match projection.default_scene_route() {
703 GltfAddressabilityProjectionV2::Available { value } => {
704 if parse_label_index(value, "Scene") != readback.observation.default_scene {
705 mismatch.push(BevyConformanceCodeV1::DefaultSceneMismatch);
706 }
707 }
708 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
709 unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable)
710 }
711 GltfAddressabilityProjectionV2::ProvenAbsent => {
712 if readback.observation.default_scene.is_some() {
713 mismatch.push(BevyConformanceCodeV1::DefaultSceneMismatch);
714 }
715 }
716 }
717 let expected_inverse = projection
718 .skins()
719 .iter()
720 .map(|skin| {
724 BevyIndexedLabelV1::new(
725 skin.source_skin_index() as u32,
726 skin.inverse_bind_matrices_label().into(),
727 )
728 })
729 .collect::<Vec<_>>();
730 if expected_inverse != readback.observation.inverse_bind_matrices {
731 mismatch.push(BevyConformanceCodeV1::SkinMismatch);
732 }
733 let expected_named = projection
734 .named_maps()
735 .iter()
736 .find(|map| map.kind() == GltfAddressabilityNamedMapKindV2::Animation)
737 .and_then(|map| match map.winners() {
738 GltfAddressabilityProjectionV2::Available { value } => Some(
739 value
740 .iter()
741 .map(|row| BevyNamedWinnerV1::new(row.name().into(), row.source_index() as u32))
742 .collect::<Vec<_>>(),
743 ),
744 GltfAddressabilityProjectionV2::ProvenAbsent => Some(Vec::new()),
745 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => None,
746 });
747 match expected_named {
748 Some(expected) if expected != readback.observation.named_animation_winners => {
749 mismatch.push(BevyConformanceCodeV1::NamedWinnerMismatch)
750 }
751 None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
752 _ => {}
753 }
754 for (kind, observed) in [
755 (
756 GltfAddressabilityNamedMapKindV2::Scene,
757 &readback.observation.named_scene_winners,
758 ),
759 (
760 GltfAddressabilityNamedMapKindV2::Skin,
761 &readback.observation.named_skin_winners,
762 ),
763 ] {
764 let expected = projection
765 .named_maps()
766 .iter()
767 .find(|map| map.kind() == kind)
768 .and_then(|map| match map.winners() {
769 GltfAddressabilityProjectionV2::Available { value } => Some(
770 value
771 .iter()
772 .map(|row| {
773 BevyNamedWinnerV1::new(row.name().into(), row.source_index() as u32)
774 })
775 .collect::<Vec<_>>(),
776 ),
777 GltfAddressabilityProjectionV2::ProvenAbsent => Some(Vec::new()),
778 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => None,
779 });
780 match expected {
781 Some(expected) if expected != *observed => {
782 mismatch.push(BevyConformanceCodeV1::NamedWinnerMismatch)
783 }
784 None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
785 _ => {}
786 }
787 }
788 let mut expected_targets = Vec::new();
789 let mut targets_available = !matches!(
790 projection.target_coverage(),
791 GltfAddressabilityProjectionV2::RequiredUnavailable { .. }
792 );
793 for target in projection.targets() {
794 match target.projection() {
795 GltfAddressabilityProjectionV2::Available { value } => {
796 for channel in target.contributing_channels() {
797 expected_targets.push(BevyAnimationTargetV1::new(
798 channel.source_animation_index() as u32,
799 value.uuid().into(),
800 ));
801 }
802 }
803 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
804 targets_available = false;
805 }
806 GltfAddressabilityProjectionV2::ProvenAbsent => {}
807 }
808 }
809 expected_targets
810 .sort_by(|a, b| (a.animation_index, &a.target_id).cmp(&(b.animation_index, &b.target_id)));
811 expected_targets.dedup();
812 compare_available_prediction_facet(
813 targets_available.then_some(expected_targets),
814 &readback.observation.targets,
815 BevyConformanceCodeV1::TargetMismatch,
816 &mut mismatch,
817 &mut unavailable,
818 );
819 mismatch.sort();
820 mismatch.dedup();
821 unavailable.sort();
822 unavailable.dedup();
823 if mismatch.is_empty() && unavailable.is_empty() {
824 BevyConformanceV1::Exact
825 } else {
826 BevyConformanceV1::NotExact {
827 mismatch_codes: mismatch,
828 unavailable_codes: unavailable,
829 }
830 }
831}
832
833fn compare_available_prediction_facet<T: PartialEq>(
834 expected: Option<T>,
835 observed: &T,
836 mismatch_code: BevyConformanceCodeV1,
837 mismatch: &mut Vec<BevyConformanceCodeV1>,
838 unavailable: &mut Vec<BevyConformanceCodeV1>,
839) {
840 match expected {
841 Some(expected) if expected != *observed => mismatch.push(mismatch_code),
842 Some(_) => {}
843 None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
844 }
845}
846
847fn parse_label_index(value: &str, prefix: &str) -> Option<u32> {
848 value.strip_prefix(prefix)?.parse().ok()
849}
850
851fn validate_observation(value: &BevyObservationV1) -> Result<(), BevyReadbackV1Error> {
852 if [
853 value.animations.len(),
854 value.named_animation_winners.len(),
855 value.named_scene_winners.len(),
856 value.named_skin_winners.len(),
857 value.scenes.len(),
858 value.nodes.len(),
859 value.skins.len(),
860 value.inverse_bind_matrices.len(),
861 value.targets.len(),
862 value.warnings.len(),
863 ]
864 .iter()
865 .any(|n| *n > BEVY_READBACK_V1_MAX_ROWS)
866 {
867 return Err(BevyReadbackV1Error::Contract("domain row limit"));
868 }
869 if value.animations.len()
870 + value.named_animation_winners.len()
871 + value.named_scene_winners.len()
872 + value.named_skin_winners.len()
873 + value.scenes.len()
874 + value.nodes.len()
875 + value.skins.len()
876 + value.inverse_bind_matrices.len()
877 + value.targets.len()
878 + value.warnings.len()
879 > BEVY_READBACK_V1_MAX_WORK
880 {
881 return Err(BevyReadbackV1Error::Contract("aggregate work limit"));
882 }
883 for rows in [
884 &value.animations,
885 &value.scenes,
886 &value.nodes,
887 &value.skins,
888 &value.inverse_bind_matrices,
889 ] {
890 if !rows.windows(2).all(|p| p[0].index < p[1].index)
891 || rows.iter().any(|row| !safe(&row.label))
892 {
893 return Err(BevyReadbackV1Error::Contract("noncanonical indexed rows"));
894 }
895 }
896 for winners in [
897 &value.named_animation_winners,
898 &value.named_scene_winners,
899 &value.named_skin_winners,
900 ] {
901 if !winners
902 .windows(2)
903 .all(|p| (&p[0].name, p[0].index) < (&p[1].name, p[1].index))
904 || winners.iter().any(|row| !safe(&row.name))
905 {
906 return Err(BevyReadbackV1Error::Contract("noncanonical named winners"));
907 }
908 }
909 if !value
910 .targets
911 .windows(2)
912 .all(|p| (p[0].animation_index, &p[0].target_id) < (p[1].animation_index, &p[1].target_id))
913 || value.targets.iter().any(|row| !safe(&row.target_id))
914 {
915 return Err(BevyReadbackV1Error::Contract("noncanonical targets"));
916 }
917 if !value
918 .warnings
919 .windows(2)
920 .all(|p| (&p[0].target, &p[0].level) < (&p[1].target, &p[1].level))
921 || value
922 .warnings
923 .iter()
924 .any(|row| !safe(&row.target) || !safe(&row.level))
925 {
926 return Err(BevyReadbackV1Error::Contract("noncanonical warnings"));
927 }
928 if !matches!(value.terminal, BevyTerminalStateV1::Loaded)
929 && (!value.animations.is_empty()
930 || !value.named_animation_winners.is_empty()
931 || !value.named_scene_winners.is_empty()
932 || !value.named_skin_winners.is_empty()
933 || value.default_scene.is_some()
934 || !value.scenes.is_empty()
935 || !value.nodes.is_empty()
936 || !value.skins.is_empty()
937 || !value.inverse_bind_matrices.is_empty()
938 || !value.targets.is_empty())
939 {
940 return Err(BevyReadbackV1Error::Contract("failed load has inventory"));
941 }
942 Ok(())
943}
944fn validate_conformance(value: &BevyConformanceV1) -> Result<(), BevyReadbackV1Error> {
945 match value {
946 BevyConformanceV1::Exact => Ok(()),
947 BevyConformanceV1::NotExact {
948 mismatch_codes,
949 unavailable_codes,
950 } if !(mismatch_codes.is_empty() && unavailable_codes.is_empty())
951 && mismatch_codes.windows(2).all(|p| p[0] < p[1])
952 && unavailable_codes.windows(2).all(|p| p[0] < p[1]) =>
953 {
954 Ok(())
955 }
956 _ => Err(BevyReadbackV1Error::Contract("noncanonical conformance")),
957 }
958}
959fn safe(value: &str) -> bool {
960 !value.is_empty() && value.len() <= MAX_TEXT_BYTES && !value.chars().any(char::is_control)
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use crate::GltfAddressabilityV2;
967
968 fn frozen_lock_identity() -> InputIdentity {
969 InputIdentity::from_sha256_digest(
970 [
971 0xe3, 0x45, 0x7e, 0x21, 0x69, 0x58, 0x74, 0xf9, 0x01, 0x10, 0xe5, 0xb9, 0x3a, 0x36,
972 0xd7, 0x68, 0x8c, 0x13, 0x4a, 0x3b, 0x92, 0x59, 0xcb, 0x1d, 0xc4, 0xd2, 0xd8, 0xe5,
973 0xf7, 0x41, 0xd1, 0xed,
974 ],
975 BEVY_READBACK_V1_LOCK_BYTES,
976 )
977 }
978
979 fn valid_readback(warnings_truncated: bool) -> BevyReadbackV1 {
980 BevyReadbackV1::new(
981 BevyHarnessIdentityV1::new(
982 "0.1.0".into(),
983 BEVY_READBACK_V1_RUSTC.into(),
984 true,
985 true,
986 frozen_lock_identity(),
987 1,
988 ),
989 InputIdentity::from_bytes(b"asset"),
990 BevyPredictionReferenceV1::new(
991 InputIdentity::from_bytes(b"p"),
992 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
993 InputIdentity::from_bytes(b"q"),
994 ),
995 BevyObservationV1::new(
996 BevyTerminalStateV1::Loaded,
997 vec![],
998 vec![],
999 vec![],
1000 vec![],
1001 None,
1002 vec![],
1003 vec![],
1004 vec![],
1005 vec![],
1006 vec![],
1007 vec![],
1008 warnings_truncated,
1009 true,
1010 true,
1011 ),
1012 BevyConformanceV1::Exact,
1013 )
1014 .unwrap()
1015 }
1016
1017 #[test]
1018 fn invalid_harness_cannot_form_a_readback() {
1019 let v = BevyReadbackV1::new(
1020 BevyHarnessIdentityV1::new(
1021 "0.1.0".into(),
1022 BEVY_READBACK_V1_RUSTC.into(),
1023 true,
1024 true,
1025 InputIdentity::from_bytes(b"not-the-tool-lock"),
1026 1,
1027 ),
1028 InputIdentity::from_bytes(b"asset"),
1029 BevyPredictionReferenceV1::new(
1030 InputIdentity::from_bytes(b"p"),
1031 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1032 InputIdentity::from_bytes(b"q"),
1033 ),
1034 BevyObservationV1::new(
1035 BevyTerminalStateV1::Loaded,
1036 vec![],
1037 vec![],
1038 vec![],
1039 vec![],
1040 None,
1041 vec![],
1042 vec![],
1043 vec![],
1044 vec![],
1045 vec![],
1046 vec![],
1047 false,
1048 true,
1049 true,
1050 ),
1051 BevyConformanceV1::Exact,
1052 );
1053 assert!(v.is_err());
1054 }
1055
1056 #[test]
1057 fn warning_truncation_is_identity_bound_and_strictly_read() {
1058 let readback = valid_readback(true);
1059 assert!(readback.observation().warnings_truncated());
1060 let mut value = serde_json::to_value(readback).unwrap();
1061 value["observation"]["warnings_truncated"] = serde_json::Value::Bool(false);
1062 assert!(validate_bevy_readback_v1(serde_json::to_vec(&value).unwrap().as_slice()).is_err());
1063 }
1064
1065 #[test]
1066 fn strict_reader_rejects_mutated_public_contract_fields() {
1067 let source = serde_json::to_value(valid_readback(false)).unwrap();
1068 for (pointer, replacement) in [
1069 ("/schema_version", serde_json::json!(2)),
1070 ("/schema", serde_json::json!("urn:animsmith:schema:other:1")),
1071 ("/harness/engine", serde_json::json!("other")),
1072 ("/harness/tool_version", serde_json::json!("0.1.1")),
1073 ("/harness/rust_toolchain", serde_json::json!("rustc 1.95.0")),
1074 ("/harness/load_animations", serde_json::json!(false)),
1075 ("/harness/lock_identity/sha256", serde_json::json!("00")),
1076 ("/input/sha256", serde_json::json!("00")),
1077 ("/prediction/provenance_schema", serde_json::json!("other")),
1078 (
1079 "/observation/terminal",
1080 serde_json::json!({"state":"work_limit"}),
1081 ),
1082 (
1083 "/observation/animations",
1084 serde_json::json!([{"index": 0, "label": "Animation0"}]),
1085 ),
1086 ] {
1087 let mut changed = source.clone();
1088 *changed.pointer_mut(pointer).unwrap() = replacement;
1089 let bytes = serde_json::to_vec(&changed).unwrap();
1090 assert!(
1091 validate_bevy_readback_v1(bytes.as_slice()).is_err(),
1092 "{pointer}"
1093 );
1094 }
1095 let bytes = serde_json::to_vec(&source).unwrap();
1096 assert!(GltfAddressabilityV2::read_from(bytes.as_slice()).is_err());
1097 }
1098
1099 #[test]
1100 fn strict_reader_then_prediction_recomputation_rejects_wrong_conformance() {
1101 let mut prediction_json = crate::addressability_v2::tests::adapter_report_json(Some(
1102 crate::TargetPointerWidth::Bits64,
1103 ));
1104 prediction_json["bevy"] = serde_json::Value::Null;
1105 let prediction_bytes = serde_json::to_vec(&prediction_json).unwrap();
1106 let prediction_input = InputIdentity::from_bytes(&prediction_bytes);
1107 let prediction = GltfAddressabilityV2::read_from(prediction_bytes.as_slice()).unwrap();
1108 let deliberately_wrong = BevyReadbackV1::new(
1109 BevyHarnessIdentityV1::new(
1110 "0.1.0".into(),
1111 BEVY_READBACK_V1_RUSTC.into(),
1112 true,
1113 true,
1114 frozen_lock_identity(),
1115 1,
1116 ),
1117 prediction.input().clone(),
1118 BevyPredictionReferenceV1::new(
1119 prediction_input.clone(),
1120 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1121 InputIdentity::from_bytes(b"adapter unavailable has no V4 reference"),
1122 ),
1123 BevyObservationV1::new(
1124 BevyTerminalStateV1::Loaded,
1125 vec![],
1126 vec![],
1127 vec![],
1128 vec![],
1129 None,
1130 vec![],
1131 vec![],
1132 vec![],
1133 vec![],
1134 vec![],
1135 vec![],
1136 false,
1137 true,
1138 true,
1139 ),
1140 BevyConformanceV1::Exact,
1141 )
1142 .unwrap();
1143 let bytes = serde_json::to_vec(&deliberately_wrong).unwrap();
1144 let strict = validate_bevy_readback_v1(bytes.as_slice()).unwrap();
1145 assert_eq!(
1146 compare_bevy_readback_v1(&strict, &prediction, &prediction_input),
1147 BevyConformanceV1::NotExact {
1148 mismatch_codes: vec![BevyConformanceCodeV1::BevyAdapterUnavailable],
1149 unavailable_codes: vec![],
1150 }
1151 );
1152 assert!(
1153 validate_bevy_readback_prediction_v1(&strict, &prediction, &prediction_input).is_err()
1154 );
1155 }
1156
1157 #[test]
1158 fn terminal_failure_readbacks_are_strict_and_non_exact() {
1159 for terminal in [
1160 BevyTerminalStateV1::RootFailure {
1161 error: BevyLoadErrorCodeV1::AssetReader,
1162 },
1163 BevyTerminalStateV1::DependencyFailure {
1164 error: BevyLoadErrorCodeV1::AssetReader,
1165 },
1166 ] {
1167 let readback = BevyReadbackV1::new(
1168 BevyHarnessIdentityV1::new(
1169 "0.1.0".into(),
1170 BEVY_READBACK_V1_RUSTC.into(),
1171 true,
1172 true,
1173 frozen_lock_identity(),
1174 1,
1175 ),
1176 InputIdentity::from_bytes(b"asset"),
1177 BevyPredictionReferenceV1::new(
1178 InputIdentity::from_bytes(b"p"),
1179 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1180 InputIdentity::from_bytes(b"q"),
1181 ),
1182 BevyObservationV1::new(
1183 terminal,
1184 vec![],
1185 vec![],
1186 vec![],
1187 vec![],
1188 None,
1189 vec![],
1190 vec![],
1191 vec![],
1192 vec![],
1193 vec![],
1194 vec![],
1195 false,
1196 true,
1197 true,
1198 ),
1199 BevyConformanceV1::NotExact {
1200 mismatch_codes: vec![BevyConformanceCodeV1::LoadDidNotSucceed],
1201 unavailable_codes: vec![],
1202 },
1203 )
1204 .unwrap();
1205 let bytes = serde_json::to_vec(&readback).unwrap();
1206 let parsed = validate_bevy_readback_v1(bytes.as_slice()).unwrap();
1207 assert!(matches!(
1208 parsed.conformance(),
1209 BevyConformanceV1::NotExact { .. }
1210 ));
1211 assert!(matches!(
1212 parsed.observation.terminal,
1213 BevyTerminalStateV1::RootFailure { .. }
1214 | BevyTerminalStateV1::DependencyFailure { .. }
1215 ));
1216 }
1217 }
1218
1219 #[test]
1220 fn exact_rejects_false_terminal_and_snapshot_mutation_matrix() {
1221 for (terminal, primary_verified, dependencies_verified) in [
1222 (BevyTerminalStateV1::Loaded, false, true),
1223 (BevyTerminalStateV1::Loaded, true, false),
1224 (BevyTerminalStateV1::WorkLimit, true, true),
1225 (
1226 BevyTerminalStateV1::RootFailure {
1227 error: BevyLoadErrorCodeV1::AssetReader,
1228 },
1229 true,
1230 true,
1231 ),
1232 (
1233 BevyTerminalStateV1::DependencyFailure {
1234 error: BevyLoadErrorCodeV1::AssetReader,
1235 },
1236 true,
1237 true,
1238 ),
1239 ] {
1240 let observation = BevyObservationV1::new(
1241 terminal,
1242 vec![],
1243 vec![],
1244 vec![],
1245 vec![],
1246 None,
1247 vec![],
1248 vec![],
1249 vec![],
1250 vec![],
1251 vec![],
1252 vec![],
1253 false,
1254 primary_verified,
1255 dependencies_verified,
1256 );
1257 let result = BevyReadbackV1::new(
1258 BevyHarnessIdentityV1::new(
1259 "0.1.0".into(),
1260 BEVY_READBACK_V1_RUSTC.into(),
1261 true,
1262 true,
1263 frozen_lock_identity(),
1264 1,
1265 ),
1266 InputIdentity::from_bytes(b"asset"),
1267 BevyPredictionReferenceV1::new(
1268 InputIdentity::from_bytes(b"p"),
1269 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1270 InputIdentity::from_bytes(b"q"),
1271 ),
1272 observation,
1273 BevyConformanceV1::Exact,
1274 );
1275 assert!(result.is_err());
1276 }
1277 }
1278
1279 #[test]
1280 fn canonical_writer_enforces_exact_limit_and_n_plus_one() {
1281 let exact_payload = "x".repeat(BEVY_READBACK_V1_MAX_REPORT_BYTES as usize - 2);
1282 assert_eq!(
1283 bounded_jcs_with_limit(&exact_payload, BEVY_READBACK_V1_MAX_REPORT_BYTES as usize)
1284 .unwrap()
1285 .len() as u64,
1286 BEVY_READBACK_V1_MAX_REPORT_BYTES
1287 );
1288 assert!(matches!(
1289 bounded_jcs_with_limit(
1290 &format!("{exact_payload}x"),
1291 BEVY_READBACK_V1_MAX_REPORT_BYTES as usize,
1292 ),
1293 Err(BevyReadbackV1Error::CanonicalTooLarge { .. })
1294 ));
1295 }
1296
1297 #[test]
1298 fn constructor_cannot_publish_a_report_larger_than_the_strict_reader_limit() {
1299 let text = "x".repeat(MAX_TEXT_BYTES - 4);
1300 let labels = (0..BEVY_READBACK_V1_MAX_ROWS as u32)
1301 .map(|index| BevyIndexedLabelV1::new(index, format!("{index:04}{text}")))
1302 .collect::<Vec<_>>();
1303 let warnings = (0..BEVY_READBACK_V1_MAX_ROWS as u32)
1304 .map(|index| BevyWarningV1::new(format!("{index:04}{text}"), text.clone()))
1305 .collect::<Vec<_>>();
1306 assert!(matches!(
1307 BevyReadbackV1::new(
1308 BevyHarnessIdentityV1::new(
1309 "0.1.0".into(),
1310 BEVY_READBACK_V1_RUSTC.into(),
1311 true,
1312 true,
1313 frozen_lock_identity(),
1314 1,
1315 ),
1316 InputIdentity::from_bytes(b"asset"),
1317 BevyPredictionReferenceV1::new(
1318 InputIdentity::from_bytes(b"p"),
1319 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1320 InputIdentity::from_bytes(b"q"),
1321 ),
1322 BevyObservationV1::new(
1323 BevyTerminalStateV1::Loaded,
1324 labels.clone(),
1325 vec![],
1326 vec![],
1327 vec![],
1328 None,
1329 labels.clone(),
1330 labels,
1331 vec![],
1332 vec![],
1333 vec![],
1334 warnings,
1335 false,
1336 true,
1337 true,
1338 ),
1339 BevyConformanceV1::Exact,
1340 ),
1341 Err(BevyReadbackV1Error::CanonicalTooLarge { .. })
1342 ));
1343 }
1344
1345 #[test]
1346 fn unavailable_facet_never_manufactures_a_mismatch_code() {
1347 for mismatch_code in [
1348 BevyConformanceCodeV1::InventoryMismatch,
1349 BevyConformanceCodeV1::SkinMismatch,
1350 BevyConformanceCodeV1::TargetMismatch,
1351 ] {
1352 let mut mismatch = Vec::new();
1353 let mut unavailable = Vec::new();
1354 compare_available_prediction_facet(
1355 None::<Vec<BevyIndexedLabelV1>>,
1356 &vec![BevyIndexedLabelV1::new(0, "observed".into())],
1357 mismatch_code,
1358 &mut mismatch,
1359 &mut unavailable,
1360 );
1361 assert_eq!(mismatch, Vec::new());
1362 assert_eq!(
1363 unavailable,
1364 vec![BevyConformanceCodeV1::RequiredPredictionUnavailable]
1365 );
1366 }
1367 }
1368}