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_392;
30pub const BEVY_READBACK_V1_LOCK_SHA256: &str =
32 "8c960a8b60623fe5a26af4c91ad2a2c2ec1cd1684f4e7264898eca757de11101";
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 0x8c, 0x96, 0x0a, 0x8b, 0x60, 0x62, 0x3f, 0xe5, 0xa2, 0x6a, 0xf4, 0xc9, 0x1a, 0xd2,
972 0xa2, 0xc2, 0xec, 0x1c, 0xd1, 0x68, 0x4f, 0x4e, 0x72, 0x64, 0x89, 0x8e, 0xca, 0x75,
973 0x7d, 0xe1, 0x11, 0x01,
974 ],
975 BEVY_READBACK_V1_LOCK_BYTES,
976 )
977 }
978
979 #[test]
980 fn strict_reader_rejects_the_previous_mixed_bevy_patch_graph() {
981 let mut readback = valid_readback(false);
982 readback.harness.lock_identity = InputIdentity::from_sha256_digest(
983 [
984 0xe3, 0x45, 0x7e, 0x21, 0x69, 0x58, 0x74, 0xf9, 0x01, 0x10, 0xe5, 0xb9, 0x3a, 0x36,
985 0xd7, 0x68, 0x8c, 0x13, 0x4a, 0x3b, 0x92, 0x59, 0xcb, 0x1d, 0xc4, 0xd2, 0xd8, 0xe5,
986 0xf7, 0x41, 0xd1, 0xed,
987 ],
988 86_390,
989 );
990 let bytes = serde_json::to_vec(&readback).unwrap();
991 assert!(matches!(
992 validate_bevy_readback_v1(bytes.as_slice()),
993 Err(BevyReadbackV1Error::Contract(
994 "invalid frozen harness tuple"
995 ))
996 ));
997 }
998
999 fn valid_readback(warnings_truncated: bool) -> BevyReadbackV1 {
1000 BevyReadbackV1::new(
1001 BevyHarnessIdentityV1::new(
1002 "0.1.0".into(),
1003 BEVY_READBACK_V1_RUSTC.into(),
1004 true,
1005 true,
1006 frozen_lock_identity(),
1007 1,
1008 ),
1009 InputIdentity::from_bytes(b"asset"),
1010 BevyPredictionReferenceV1::new(
1011 InputIdentity::from_bytes(b"p"),
1012 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1013 InputIdentity::from_bytes(b"q"),
1014 ),
1015 BevyObservationV1::new(
1016 BevyTerminalStateV1::Loaded,
1017 vec![],
1018 vec![],
1019 vec![],
1020 vec![],
1021 None,
1022 vec![],
1023 vec![],
1024 vec![],
1025 vec![],
1026 vec![],
1027 vec![],
1028 warnings_truncated,
1029 true,
1030 true,
1031 ),
1032 BevyConformanceV1::Exact,
1033 )
1034 .unwrap()
1035 }
1036
1037 #[test]
1038 fn invalid_harness_cannot_form_a_readback() {
1039 let v = BevyReadbackV1::new(
1040 BevyHarnessIdentityV1::new(
1041 "0.1.0".into(),
1042 BEVY_READBACK_V1_RUSTC.into(),
1043 true,
1044 true,
1045 InputIdentity::from_bytes(b"not-the-tool-lock"),
1046 1,
1047 ),
1048 InputIdentity::from_bytes(b"asset"),
1049 BevyPredictionReferenceV1::new(
1050 InputIdentity::from_bytes(b"p"),
1051 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1052 InputIdentity::from_bytes(b"q"),
1053 ),
1054 BevyObservationV1::new(
1055 BevyTerminalStateV1::Loaded,
1056 vec![],
1057 vec![],
1058 vec![],
1059 vec![],
1060 None,
1061 vec![],
1062 vec![],
1063 vec![],
1064 vec![],
1065 vec![],
1066 vec![],
1067 false,
1068 true,
1069 true,
1070 ),
1071 BevyConformanceV1::Exact,
1072 );
1073 assert!(v.is_err());
1074 }
1075
1076 #[test]
1077 fn warning_truncation_is_identity_bound_and_strictly_read() {
1078 let readback = valid_readback(true);
1079 assert!(readback.observation().warnings_truncated());
1080 let mut value = serde_json::to_value(readback).unwrap();
1081 value["observation"]["warnings_truncated"] = serde_json::Value::Bool(false);
1082 assert!(validate_bevy_readback_v1(serde_json::to_vec(&value).unwrap().as_slice()).is_err());
1083 }
1084
1085 #[test]
1086 fn strict_reader_rejects_mutated_public_contract_fields() {
1087 let source = serde_json::to_value(valid_readback(false)).unwrap();
1088 for (pointer, replacement) in [
1089 ("/schema_version", serde_json::json!(2)),
1090 ("/schema", serde_json::json!("urn:animsmith:schema:other:1")),
1091 ("/harness/engine", serde_json::json!("other")),
1092 ("/harness/tool_version", serde_json::json!("0.1.1")),
1093 ("/harness/rust_toolchain", serde_json::json!("rustc 1.95.0")),
1094 ("/harness/load_animations", serde_json::json!(false)),
1095 ("/harness/lock_identity/sha256", serde_json::json!("00")),
1096 ("/input/sha256", serde_json::json!("00")),
1097 ("/prediction/provenance_schema", serde_json::json!("other")),
1098 (
1099 "/observation/terminal",
1100 serde_json::json!({"state":"work_limit"}),
1101 ),
1102 (
1103 "/observation/animations",
1104 serde_json::json!([{"index": 0, "label": "Animation0"}]),
1105 ),
1106 ] {
1107 let mut changed = source.clone();
1108 *changed.pointer_mut(pointer).unwrap() = replacement;
1109 let bytes = serde_json::to_vec(&changed).unwrap();
1110 assert!(
1111 validate_bevy_readback_v1(bytes.as_slice()).is_err(),
1112 "{pointer}"
1113 );
1114 }
1115 let bytes = serde_json::to_vec(&source).unwrap();
1116 assert!(GltfAddressabilityV2::read_from(bytes.as_slice()).is_err());
1117 }
1118
1119 #[test]
1120 fn strict_reader_then_prediction_recomputation_rejects_wrong_conformance() {
1121 let mut prediction_json = crate::addressability_v2::tests::adapter_report_json(Some(
1122 crate::TargetPointerWidth::Bits64,
1123 ));
1124 prediction_json["bevy"] = serde_json::Value::Null;
1125 let prediction_bytes = serde_json::to_vec(&prediction_json).unwrap();
1126 let prediction_input = InputIdentity::from_bytes(&prediction_bytes);
1127 let prediction = GltfAddressabilityV2::read_from(prediction_bytes.as_slice()).unwrap();
1128 let deliberately_wrong = BevyReadbackV1::new(
1129 BevyHarnessIdentityV1::new(
1130 "0.1.0".into(),
1131 BEVY_READBACK_V1_RUSTC.into(),
1132 true,
1133 true,
1134 frozen_lock_identity(),
1135 1,
1136 ),
1137 prediction.input().clone(),
1138 BevyPredictionReferenceV1::new(
1139 prediction_input.clone(),
1140 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1141 InputIdentity::from_bytes(b"adapter unavailable has no V4 reference"),
1142 ),
1143 BevyObservationV1::new(
1144 BevyTerminalStateV1::Loaded,
1145 vec![],
1146 vec![],
1147 vec![],
1148 vec![],
1149 None,
1150 vec![],
1151 vec![],
1152 vec![],
1153 vec![],
1154 vec![],
1155 vec![],
1156 false,
1157 true,
1158 true,
1159 ),
1160 BevyConformanceV1::Exact,
1161 )
1162 .unwrap();
1163 let bytes = serde_json::to_vec(&deliberately_wrong).unwrap();
1164 let strict = validate_bevy_readback_v1(bytes.as_slice()).unwrap();
1165 assert_eq!(
1166 compare_bevy_readback_v1(&strict, &prediction, &prediction_input),
1167 BevyConformanceV1::NotExact {
1168 mismatch_codes: vec![BevyConformanceCodeV1::BevyAdapterUnavailable],
1169 unavailable_codes: vec![],
1170 }
1171 );
1172 assert!(
1173 validate_bevy_readback_prediction_v1(&strict, &prediction, &prediction_input).is_err()
1174 );
1175 }
1176
1177 #[test]
1178 fn terminal_failure_readbacks_are_strict_and_non_exact() {
1179 for terminal in [
1180 BevyTerminalStateV1::RootFailure {
1181 error: BevyLoadErrorCodeV1::AssetReader,
1182 },
1183 BevyTerminalStateV1::DependencyFailure {
1184 error: BevyLoadErrorCodeV1::AssetReader,
1185 },
1186 ] {
1187 let readback = BevyReadbackV1::new(
1188 BevyHarnessIdentityV1::new(
1189 "0.1.0".into(),
1190 BEVY_READBACK_V1_RUSTC.into(),
1191 true,
1192 true,
1193 frozen_lock_identity(),
1194 1,
1195 ),
1196 InputIdentity::from_bytes(b"asset"),
1197 BevyPredictionReferenceV1::new(
1198 InputIdentity::from_bytes(b"p"),
1199 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1200 InputIdentity::from_bytes(b"q"),
1201 ),
1202 BevyObservationV1::new(
1203 terminal,
1204 vec![],
1205 vec![],
1206 vec![],
1207 vec![],
1208 None,
1209 vec![],
1210 vec![],
1211 vec![],
1212 vec![],
1213 vec![],
1214 vec![],
1215 false,
1216 true,
1217 true,
1218 ),
1219 BevyConformanceV1::NotExact {
1220 mismatch_codes: vec![BevyConformanceCodeV1::LoadDidNotSucceed],
1221 unavailable_codes: vec![],
1222 },
1223 )
1224 .unwrap();
1225 let bytes = serde_json::to_vec(&readback).unwrap();
1226 let parsed = validate_bevy_readback_v1(bytes.as_slice()).unwrap();
1227 assert!(matches!(
1228 parsed.conformance(),
1229 BevyConformanceV1::NotExact { .. }
1230 ));
1231 assert!(matches!(
1232 parsed.observation.terminal,
1233 BevyTerminalStateV1::RootFailure { .. }
1234 | BevyTerminalStateV1::DependencyFailure { .. }
1235 ));
1236 }
1237 }
1238
1239 #[test]
1240 fn exact_rejects_false_terminal_and_snapshot_mutation_matrix() {
1241 for (terminal, primary_verified, dependencies_verified) in [
1242 (BevyTerminalStateV1::Loaded, false, true),
1243 (BevyTerminalStateV1::Loaded, true, false),
1244 (BevyTerminalStateV1::WorkLimit, true, true),
1245 (
1246 BevyTerminalStateV1::RootFailure {
1247 error: BevyLoadErrorCodeV1::AssetReader,
1248 },
1249 true,
1250 true,
1251 ),
1252 (
1253 BevyTerminalStateV1::DependencyFailure {
1254 error: BevyLoadErrorCodeV1::AssetReader,
1255 },
1256 true,
1257 true,
1258 ),
1259 ] {
1260 let observation = BevyObservationV1::new(
1261 terminal,
1262 vec![],
1263 vec![],
1264 vec![],
1265 vec![],
1266 None,
1267 vec![],
1268 vec![],
1269 vec![],
1270 vec![],
1271 vec![],
1272 vec![],
1273 false,
1274 primary_verified,
1275 dependencies_verified,
1276 );
1277 let result = BevyReadbackV1::new(
1278 BevyHarnessIdentityV1::new(
1279 "0.1.0".into(),
1280 BEVY_READBACK_V1_RUSTC.into(),
1281 true,
1282 true,
1283 frozen_lock_identity(),
1284 1,
1285 ),
1286 InputIdentity::from_bytes(b"asset"),
1287 BevyPredictionReferenceV1::new(
1288 InputIdentity::from_bytes(b"p"),
1289 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1290 InputIdentity::from_bytes(b"q"),
1291 ),
1292 observation,
1293 BevyConformanceV1::Exact,
1294 );
1295 assert!(result.is_err());
1296 }
1297 }
1298
1299 #[test]
1300 fn canonical_writer_enforces_exact_limit_and_n_plus_one() {
1301 let exact_payload = "x".repeat(BEVY_READBACK_V1_MAX_REPORT_BYTES as usize - 2);
1302 assert_eq!(
1303 bounded_jcs_with_limit(&exact_payload, BEVY_READBACK_V1_MAX_REPORT_BYTES as usize)
1304 .unwrap()
1305 .len() as u64,
1306 BEVY_READBACK_V1_MAX_REPORT_BYTES
1307 );
1308 assert!(matches!(
1309 bounded_jcs_with_limit(
1310 &format!("{exact_payload}x"),
1311 BEVY_READBACK_V1_MAX_REPORT_BYTES as usize,
1312 ),
1313 Err(BevyReadbackV1Error::CanonicalTooLarge { .. })
1314 ));
1315 }
1316
1317 #[test]
1318 fn constructor_cannot_publish_a_report_larger_than_the_strict_reader_limit() {
1319 let text = "x".repeat(MAX_TEXT_BYTES - 4);
1320 let labels = (0..BEVY_READBACK_V1_MAX_ROWS as u32)
1321 .map(|index| BevyIndexedLabelV1::new(index, format!("{index:04}{text}")))
1322 .collect::<Vec<_>>();
1323 let warnings = (0..BEVY_READBACK_V1_MAX_ROWS as u32)
1324 .map(|index| BevyWarningV1::new(format!("{index:04}{text}"), text.clone()))
1325 .collect::<Vec<_>>();
1326 assert!(matches!(
1327 BevyReadbackV1::new(
1328 BevyHarnessIdentityV1::new(
1329 "0.1.0".into(),
1330 BEVY_READBACK_V1_RUSTC.into(),
1331 true,
1332 true,
1333 frozen_lock_identity(),
1334 1,
1335 ),
1336 InputIdentity::from_bytes(b"asset"),
1337 BevyPredictionReferenceV1::new(
1338 InputIdentity::from_bytes(b"p"),
1339 animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1340 InputIdentity::from_bytes(b"q"),
1341 ),
1342 BevyObservationV1::new(
1343 BevyTerminalStateV1::Loaded,
1344 labels.clone(),
1345 vec![],
1346 vec![],
1347 vec![],
1348 None,
1349 labels.clone(),
1350 labels,
1351 vec![],
1352 vec![],
1353 vec![],
1354 warnings,
1355 false,
1356 true,
1357 true,
1358 ),
1359 BevyConformanceV1::Exact,
1360 ),
1361 Err(BevyReadbackV1Error::CanonicalTooLarge { .. })
1362 ));
1363 }
1364
1365 #[test]
1366 fn unavailable_facet_never_manufactures_a_mismatch_code() {
1367 for mismatch_code in [
1368 BevyConformanceCodeV1::InventoryMismatch,
1369 BevyConformanceCodeV1::SkinMismatch,
1370 BevyConformanceCodeV1::TargetMismatch,
1371 ] {
1372 let mut mismatch = Vec::new();
1373 let mut unavailable = Vec::new();
1374 compare_available_prediction_facet(
1375 None::<Vec<BevyIndexedLabelV1>>,
1376 &vec![BevyIndexedLabelV1::new(0, "observed".into())],
1377 mismatch_code,
1378 &mut mismatch,
1379 &mut unavailable,
1380 );
1381 assert_eq!(mismatch, Vec::new());
1382 assert_eq!(
1383 unavailable,
1384 vec![BevyConformanceCodeV1::RequiredPredictionUnavailable]
1385 );
1386 }
1387 }
1388}