1use crate::{
7 GltfAddressabilityNamedMapKindV2, GltfAddressabilityProjectionV2, GltfAddressabilityReadbackV2,
8 GltfAnimationCoverageStateV1,
9};
10use crate::bevy_readback_lock::{BEVY_READBACK_V1_LOCK_BYTES, BEVY_READBACK_V1_LOCK_SHA256};
12use animsmith_core::InputIdentity;
13use serde::{Deserialize, Serialize};
14use std::io::{self, Read, Write};
15
16pub const BEVY_READBACK_V1_ID: &str = "urn:animsmith:schema:bevy-readback:1";
18pub const BEVY_READBACK_V1_SCHEMA_VERSION: u32 = 1;
20pub const BEVY_READBACK_V1_MAX_REPORT_BYTES: u64 = 16 * 1024 * 1024;
22pub const BEVY_READBACK_V1_MAX_ROWS: usize = 4_096;
24pub const BEVY_READBACK_V1_MAX_WORK: usize = 65_536;
26pub const BEVY_READBACK_V1_MAX_UPDATES: u64 = 4_096;
28pub const BEVY_READBACK_V1_RUSTC: &str = "rustc 1.95.0 (59807616e 2026-04-14)";
30const MAX_TEXT_BYTES: usize = 1_024;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct BevyPredictionReferenceV1 {
36 prediction_input: InputIdentity,
37 provenance_schema: String,
38 provenance_identity: InputIdentity,
39}
40impl BevyPredictionReferenceV1 {
41 pub fn new(
43 prediction_input: InputIdentity,
44 provenance_schema: String,
45 provenance_identity: InputIdentity,
46 ) -> Self {
47 Self {
48 prediction_input,
49 provenance_schema,
50 provenance_identity,
51 }
52 }
53 pub const fn prediction_input(&self) -> &InputIdentity {
55 &self.prediction_input
56 }
57 pub fn provenance_schema(&self) -> &str {
59 &self.provenance_schema
60 }
61 pub const fn provenance_identity(&self) -> &InputIdentity {
63 &self.provenance_identity
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct BevyHarnessIdentityV1 {
71 engine: String,
72 engine_version: String,
73 tool_version: String,
74 rust_toolchain: String,
75 bevy_animation_feature: bool,
76 load_animations: bool,
77 lock_identity: InputIdentity,
78 updates: u64,
79}
80impl BevyHarnessIdentityV1 {
81 pub fn new(
83 tool_version: String,
84 rust_toolchain: String,
85 bevy_animation_feature: bool,
86 load_animations: bool,
87 lock_identity: InputIdentity,
88 updates: u64,
89 ) -> Self {
90 Self {
91 engine: "bevy".into(),
92 engine_version: "0.19.0".into(),
93 tool_version,
94 rust_toolchain,
95 bevy_animation_feature,
96 load_animations,
97 lock_identity,
98 updates,
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct BevyIndexedLabelV1 {
107 index: u32,
108 label: String,
109}
110impl BevyIndexedLabelV1 {
111 pub fn new(index: u32, label: String) -> Self {
113 Self { index, label }
114 }
115 pub const fn index(&self) -> u32 {
117 self.index
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct BevyNamedWinnerV1 {
125 name: String,
126 index: u32,
127}
128impl BevyNamedWinnerV1 {
129 pub fn new(name: String, index: u32) -> Self {
131 Self { name, index }
132 }
133 pub fn sort_key(&self) -> (&str, u32) {
135 (&self.name, self.index)
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct BevyAnimationTargetV1 {
143 animation_index: u32,
144 target_id: String,
145}
146impl BevyAnimationTargetV1 {
147 pub fn new(animation_index: u32, target_id: String) -> Self {
149 Self {
150 animation_index,
151 target_id,
152 }
153 }
154 pub fn sort_key(&self) -> (u32, &str) {
156 (self.animation_index, &self.target_id)
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum BevyLoadErrorCodeV1 {
164 EmptyPath,
166 RequestedHandleTypeMismatch,
168 MissingAssetLoader,
170 MissingAssetLoaderForExtension,
172 MissingAssetLoaderForTypeName,
174 MissingAssetLoaderForTypeId,
176 AssetReader,
178 MissingAssetSource,
180 MissingProcessedAssetReader,
182 AssetMetadata,
184 DeserializeMetadata,
186 CannotLoadProcessedAsset,
188 CannotLoadIgnoredAsset,
190 LoaderPanic,
192 Loader,
194 AddAsync,
196 MissingLabel,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
203pub enum BevyTerminalStateV1 {
204 Loaded,
206 RootFailure {
208 error: BevyLoadErrorCodeV1,
210 },
211 DependencyFailure {
213 error: BevyLoadErrorCodeV1,
215 },
216 WorkLimit,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct BevyWarningV1 {
224 target: String,
225 level: String,
226}
227impl BevyWarningV1 {
228 pub fn new(target: String, level: String) -> Self {
230 Self { target, level }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct BevyObservationV1 {
238 terminal: BevyTerminalStateV1,
239 animations: Vec<BevyIndexedLabelV1>,
240 named_animation_winners: Vec<BevyNamedWinnerV1>,
241 named_scene_winners: Vec<BevyNamedWinnerV1>,
242 named_skin_winners: Vec<BevyNamedWinnerV1>,
243 default_scene: Option<u32>,
244 scenes: Vec<BevyIndexedLabelV1>,
245 nodes: Vec<BevyIndexedLabelV1>,
246 skins: Vec<BevyIndexedLabelV1>,
247 inverse_bind_matrices: Vec<BevyIndexedLabelV1>,
248 targets: Vec<BevyAnimationTargetV1>,
249 warnings: Vec<BevyWarningV1>,
250 warnings_truncated: bool,
251 primary_verified: bool,
252 dependencies_verified: bool,
253}
254impl BevyObservationV1 {
255 #[allow(clippy::too_many_arguments)]
261 pub fn new(
262 terminal: BevyTerminalStateV1,
263 animations: Vec<BevyIndexedLabelV1>,
264 named_animation_winners: Vec<BevyNamedWinnerV1>,
265 named_scene_winners: Vec<BevyNamedWinnerV1>,
266 named_skin_winners: Vec<BevyNamedWinnerV1>,
267 default_scene: Option<u32>,
268 scenes: Vec<BevyIndexedLabelV1>,
269 nodes: Vec<BevyIndexedLabelV1>,
270 skins: Vec<BevyIndexedLabelV1>,
271 inverse_bind_matrices: Vec<BevyIndexedLabelV1>,
272 targets: Vec<BevyAnimationTargetV1>,
273 warnings: Vec<BevyWarningV1>,
274 warnings_truncated: bool,
275 primary_verified: bool,
276 dependencies_verified: bool,
277 ) -> Self {
278 Self {
279 terminal,
280 animations,
281 named_animation_winners,
282 named_scene_winners,
283 named_skin_winners,
284 default_scene,
285 scenes,
286 nodes,
287 skins,
288 inverse_bind_matrices,
289 targets,
290 warnings,
291 warnings_truncated,
292 primary_verified,
293 dependencies_verified,
294 }
295 }
296 pub const fn warnings_truncated(&self) -> bool {
298 self.warnings_truncated
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum BevyConformanceCodeV1 {
306 InputIdentityMismatch,
308 PredictionDocumentMismatch,
310 BevyAdapterUnavailable,
312 ProvenanceMismatch,
314 DependencyIdentityMismatch,
316 RequiredPredictionUnavailable,
318 LoadDidNotSucceed,
320 SettingsMismatch,
322 InventoryMismatch,
324 SceneMismatch,
326 DefaultSceneMismatch,
328 SkinMismatch,
330 NamedWinnerMismatch,
332 TargetMismatch,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
339pub enum BevyConformanceV1 {
340 Exact,
342 NotExact {
344 mismatch_codes: Vec<BevyConformanceCodeV1>,
346 unavailable_codes: Vec<BevyConformanceCodeV1>,
348 },
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct BevyReadbackV1 {
355 schema_version: u32,
356 schema: String,
357 identity: InputIdentity,
358 harness: BevyHarnessIdentityV1,
359 input: InputIdentity,
360 prediction: BevyPredictionReferenceV1,
361 observation: BevyObservationV1,
362 conformance: BevyConformanceV1,
363}
364impl BevyReadbackV1 {
365 pub fn new(
367 harness: BevyHarnessIdentityV1,
368 input: InputIdentity,
369 prediction: BevyPredictionReferenceV1,
370 observation: BevyObservationV1,
371 conformance: BevyConformanceV1,
372 ) -> Result<Self, BevyReadbackV1Error> {
373 let mut value = Self {
374 schema_version: 1,
375 schema: BEVY_READBACK_V1_ID.into(),
376 identity: InputIdentity::from_bytes(&[]),
377 harness,
378 input,
379 prediction,
380 observation,
381 conformance,
382 };
383 value.identity = value.computed_identity()?;
384 value.validate()?;
385 Ok(value)
386 }
387 pub const fn input(&self) -> &InputIdentity {
389 &self.input
390 }
391 pub const fn observation(&self) -> &BevyObservationV1 {
393 &self.observation
394 }
395 pub const fn conformance(&self) -> &BevyConformanceV1 {
397 &self.conformance
398 }
399 fn computed_identity(&self) -> Result<InputIdentity, BevyReadbackV1Error> {
400 bounded_jcs(&Seed {
401 schema_version: self.schema_version,
402 schema: &self.schema,
403 harness: &self.harness,
404 input: &self.input,
405 prediction: &self.prediction,
406 observation: &self.observation,
407 conformance: &self.conformance,
408 })
409 .map(|bytes| InputIdentity::from_bytes(&bytes))
410 }
411 pub fn validate(&self) -> Result<(), BevyReadbackV1Error> {
413 if self.schema_version != 1 || self.schema != BEVY_READBACK_V1_ID {
414 return Err(BevyReadbackV1Error::Contract("invalid schema header"));
415 }
416 let h = &self.harness;
417 if h.engine != "bevy"
418 || h.engine_version != "0.19.0"
419 || !safe(&h.tool_version)
420 || h.rust_toolchain != BEVY_READBACK_V1_RUSTC
421 || !h.bevy_animation_feature
422 || !h.load_animations
423 || h.lock_identity.bytes() != BEVY_READBACK_V1_LOCK_BYTES
424 || h.lock_identity.sha256() != BEVY_READBACK_V1_LOCK_SHA256
425 || h.updates > BEVY_READBACK_V1_MAX_UPDATES
426 {
427 return Err(BevyReadbackV1Error::Contract(
428 "invalid frozen harness tuple",
429 ));
430 }
431 if self.prediction.provenance_schema != animsmith_core::PREDICTION_PROVENANCE_V4_ID {
432 return Err(BevyReadbackV1Error::Contract(
433 "invalid V4 provenance header",
434 ));
435 }
436 validate_observation(&self.observation)?;
437 validate_conformance(&self.conformance)?;
438 if matches!(self.conformance, BevyConformanceV1::Exact)
439 && (!matches!(self.observation.terminal, BevyTerminalStateV1::Loaded)
440 || !self.observation.primary_verified
441 || !self.observation.dependencies_verified)
442 {
443 return Err(BevyReadbackV1Error::Contract(
444 "exact conformance requires a verified loaded observation",
445 ));
446 }
447 if self.identity != self.computed_identity()? {
448 return Err(BevyReadbackV1Error::Contract("self identity mismatch"));
449 }
450 bounded_jcs(self)?;
451 Ok(())
452 }
453}
454#[derive(Serialize)]
455struct Seed<'a> {
456 schema_version: u32,
457 schema: &'a str,
458 harness: &'a BevyHarnessIdentityV1,
459 input: &'a InputIdentity,
460 prediction: &'a BevyPredictionReferenceV1,
461 observation: &'a BevyObservationV1,
462 conformance: &'a BevyConformanceV1,
463}
464
465fn bounded_jcs(value: &impl Serialize) -> Result<Vec<u8>, BevyReadbackV1Error> {
466 bounded_jcs_with_limit(value, BEVY_READBACK_V1_MAX_REPORT_BYTES as usize)
467}
468
469fn bounded_jcs_with_limit(
470 value: &impl Serialize,
471 limit: usize,
472) -> Result<Vec<u8>, BevyReadbackV1Error> {
473 let mut writer = BoundedJcsWriter {
474 bytes: Vec::new(),
475 limit,
476 overflowed: false,
477 };
478 serde_jcs::to_writer(&mut writer, value).map_err(|error| {
479 if writer.overflowed {
480 BevyReadbackV1Error::CanonicalTooLarge {
481 limit: limit as u64,
482 }
483 } else {
484 BevyReadbackV1Error::Canonical(error)
485 }
486 })?;
487 Ok(writer.bytes)
488}
489
490struct BoundedJcsWriter {
491 bytes: Vec<u8>,
492 limit: usize,
493 overflowed: bool,
494}
495
496impl Write for BoundedJcsWriter {
497 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
498 let Some(length) = self.bytes.len().checked_add(bytes.len()) else {
499 self.overflowed = true;
500 return Err(io::Error::other("Bevy readback canonical limit"));
501 };
502 if length > self.limit {
503 self.overflowed = true;
504 return Err(io::Error::other("Bevy readback canonical limit"));
505 }
506 self.bytes.extend_from_slice(bytes);
507 Ok(bytes.len())
508 }
509
510 fn flush(&mut self) -> io::Result<()> {
511 Ok(())
512 }
513}
514
515#[derive(Debug, thiserror::Error)]
517#[non_exhaustive]
518pub enum BevyReadbackV1Error {
519 #[error("cannot read Bevy readback: {0}")]
521 Io(#[from] std::io::Error),
522 #[error("Bevy readback exceeds byte limit {limit}")]
524 ReportTooLarge {
525 limit: u64,
527 },
528 #[error("invalid Bevy readback JSON: {0}")]
530 Json(#[from] serde_json::Error),
531 #[error("cannot canonicalize Bevy readback: {0}")]
533 Canonical(serde_json::Error),
534 #[error("Bevy readback canonical bytes exceed byte limit {limit}")]
536 CanonicalTooLarge {
537 limit: u64,
539 },
540 #[error("invalid Bevy readback V1 contract: {0}")]
542 Contract(&'static str),
543}
544
545pub fn validate_bevy_readback_v1(reader: impl Read) -> Result<BevyReadbackV1, BevyReadbackV1Error> {
547 let mut bytes = Vec::new();
548 reader
549 .take(BEVY_READBACK_V1_MAX_REPORT_BYTES + 1)
550 .read_to_end(&mut bytes)?;
551 if bytes.len() as u64 > BEVY_READBACK_V1_MAX_REPORT_BYTES {
552 return Err(BevyReadbackV1Error::ReportTooLarge {
553 limit: BEVY_READBACK_V1_MAX_REPORT_BYTES,
554 });
555 }
556 let value: BevyReadbackV1 = serde_json::from_slice(&bytes)?;
557 value.validate()?;
558 Ok(value)
559}
560
561pub fn validate_bevy_readback_prediction_v1(
563 readback: &BevyReadbackV1,
564 prediction: &GltfAddressabilityReadbackV2,
565 prediction_input: &InputIdentity,
566) -> Result<(), BevyReadbackV1Error> {
567 readback.validate()?;
568 if compare_bevy_readback_v1(readback, prediction, prediction_input) != readback.conformance {
569 return Err(BevyReadbackV1Error::Contract(
570 "stored conformance differs from recomputation",
571 ));
572 }
573 Ok(())
574}
575
576pub fn compare_bevy_readback_v1(
578 readback: &BevyReadbackV1,
579 prediction: &GltfAddressabilityReadbackV2,
580 prediction_input: &InputIdentity,
581) -> BevyConformanceV1 {
582 let mut mismatch = Vec::new();
583 let mut unavailable = Vec::new();
584 if readback.input != *prediction.input() || !readback.observation.primary_verified {
585 mismatch.push(BevyConformanceCodeV1::InputIdentityMismatch);
586 }
587 if readback.prediction.prediction_input != *prediction_input {
588 mismatch.push(BevyConformanceCodeV1::PredictionDocumentMismatch);
589 }
590 let Some(adapter) = prediction.bevy() else {
591 return BevyConformanceV1::NotExact {
592 mismatch_codes: vec![BevyConformanceCodeV1::BevyAdapterUnavailable],
593 unavailable_codes: Vec::new(),
594 };
595 };
596 let provenance = adapter.prediction_provenance();
597 if readback.prediction.provenance_schema != provenance.contract_id()
598 || readback.prediction.provenance_identity != *provenance.identity().input_identity()
599 {
600 mismatch.push(BevyConformanceCodeV1::ProvenanceMismatch);
601 }
602 if !readback.observation.dependencies_verified {
603 mismatch.push(BevyConformanceCodeV1::DependencyIdentityMismatch);
604 }
605 if !matches!(readback.observation.terminal, BevyTerminalStateV1::Loaded) {
606 mismatch.push(BevyConformanceCodeV1::LoadDidNotSucceed);
607 }
608 let projection = adapter.projection();
609 if readback.harness.bevy_animation_feature != adapter.settings().bevy_animation_feature()
610 || readback.harness.load_animations != adapter.settings().load_animations()
611 {
612 mismatch.push(BevyConformanceCodeV1::SettingsMismatch);
613 }
614 let animations_available = prediction
615 .inventory()
616 .animations()
617 .animations()
618 .coverage()
619 .state()
620 == GltfAnimationCoverageStateV1::Complete;
621 let expected_animations = prediction
622 .inventory()
623 .animations()
624 .animations()
625 .rows()
626 .iter()
627 .map(|row| {
628 BevyIndexedLabelV1::new(
629 row.source_clip_index() as u32,
630 format!("Animation{}", row.source_clip_index()),
631 )
632 })
633 .collect::<Vec<_>>();
634 compare_available_prediction_facet(
635 animations_available.then_some(expected_animations),
636 &readback.observation.animations,
637 BevyConformanceCodeV1::InventoryMismatch,
638 &mut mismatch,
639 &mut unavailable,
640 );
641 let raw = prediction.inventory().raw();
642 if raw.scene_coverage().is_complete() {
643 let expected = raw
644 .scenes()
645 .iter()
646 .map(|row| {
647 BevyIndexedLabelV1::new(
648 row.source_scene_index() as u32,
649 format!("Scene{}", row.source_scene_index()),
650 )
651 })
652 .collect::<Vec<_>>();
653 if expected != readback.observation.scenes {
654 mismatch.push(BevyConformanceCodeV1::SceneMismatch);
655 }
656 } else {
657 unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable);
658 }
659 if raw.node_coverage().is_complete() {
660 let expected = raw
661 .nodes()
662 .iter()
663 .map(|row| {
664 BevyIndexedLabelV1::new(
665 row.source_node_index() as u32,
666 format!("Node{}", row.source_node_index()),
667 )
668 })
669 .collect::<Vec<_>>();
670 if expected != readback.observation.nodes {
671 mismatch.push(BevyConformanceCodeV1::InventoryMismatch);
672 }
673 } else {
674 unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable);
675 }
676 let mut expected_skins = Vec::new();
677 let mut skins_available = true;
678 for skin in projection.skins() {
679 match skin.skin_label() {
680 GltfAddressabilityProjectionV2::Available { value } => {
681 expected_skins.push(BevyIndexedLabelV1::new(
682 skin.source_skin_index() as u32,
683 value.clone(),
684 ));
685 }
686 GltfAddressabilityProjectionV2::ProvenAbsent => {}
687 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
688 skins_available = false;
689 }
690 }
691 }
692 compare_available_prediction_facet(
693 skins_available.then_some(expected_skins),
694 &readback.observation.skins,
695 BevyConformanceCodeV1::SkinMismatch,
696 &mut mismatch,
697 &mut unavailable,
698 );
699 match projection.default_scene_route() {
700 GltfAddressabilityProjectionV2::Available { value } => {
701 if parse_label_index(value, "Scene") != readback.observation.default_scene {
702 mismatch.push(BevyConformanceCodeV1::DefaultSceneMismatch);
703 }
704 }
705 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
706 unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable)
707 }
708 GltfAddressabilityProjectionV2::ProvenAbsent => {
709 if readback.observation.default_scene.is_some() {
710 mismatch.push(BevyConformanceCodeV1::DefaultSceneMismatch);
711 }
712 }
713 }
714 let expected_inverse = projection
715 .skins()
716 .iter()
717 .map(|skin| {
721 BevyIndexedLabelV1::new(
722 skin.source_skin_index() as u32,
723 skin.inverse_bind_matrices_label().into(),
724 )
725 })
726 .collect::<Vec<_>>();
727 if expected_inverse != readback.observation.inverse_bind_matrices {
728 mismatch.push(BevyConformanceCodeV1::SkinMismatch);
729 }
730 let expected_named = projection
731 .named_maps()
732 .iter()
733 .find(|map| map.kind() == GltfAddressabilityNamedMapKindV2::Animation)
734 .and_then(|map| match map.winners() {
735 GltfAddressabilityProjectionV2::Available { value } => Some(
736 value
737 .iter()
738 .map(|row| BevyNamedWinnerV1::new(row.name().into(), row.source_index() as u32))
739 .collect::<Vec<_>>(),
740 ),
741 GltfAddressabilityProjectionV2::ProvenAbsent => Some(Vec::new()),
742 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => None,
743 });
744 match expected_named {
745 Some(expected) if expected != readback.observation.named_animation_winners => {
746 mismatch.push(BevyConformanceCodeV1::NamedWinnerMismatch)
747 }
748 None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
749 _ => {}
750 }
751 for (kind, observed) in [
752 (
753 GltfAddressabilityNamedMapKindV2::Scene,
754 &readback.observation.named_scene_winners,
755 ),
756 (
757 GltfAddressabilityNamedMapKindV2::Skin,
758 &readback.observation.named_skin_winners,
759 ),
760 ] {
761 let expected = projection
762 .named_maps()
763 .iter()
764 .find(|map| map.kind() == kind)
765 .and_then(|map| match map.winners() {
766 GltfAddressabilityProjectionV2::Available { value } => Some(
767 value
768 .iter()
769 .map(|row| {
770 BevyNamedWinnerV1::new(row.name().into(), row.source_index() as u32)
771 })
772 .collect::<Vec<_>>(),
773 ),
774 GltfAddressabilityProjectionV2::ProvenAbsent => Some(Vec::new()),
775 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => None,
776 });
777 match expected {
778 Some(expected) if expected != *observed => {
779 mismatch.push(BevyConformanceCodeV1::NamedWinnerMismatch)
780 }
781 None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
782 _ => {}
783 }
784 }
785 let mut expected_targets = Vec::new();
786 let mut targets_available = !matches!(
787 projection.target_coverage(),
788 GltfAddressabilityProjectionV2::RequiredUnavailable { .. }
789 );
790 for target in projection.targets() {
791 match target.projection() {
792 GltfAddressabilityProjectionV2::Available { value } => {
793 for channel in target.contributing_channels() {
794 expected_targets.push(BevyAnimationTargetV1::new(
795 channel.source_animation_index() as u32,
796 value.uuid().into(),
797 ));
798 }
799 }
800 GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
801 targets_available = false;
802 }
803 GltfAddressabilityProjectionV2::ProvenAbsent => {}
804 }
805 }
806 expected_targets
807 .sort_by(|a, b| (a.animation_index, &a.target_id).cmp(&(b.animation_index, &b.target_id)));
808 expected_targets.dedup();
809 compare_available_prediction_facet(
810 targets_available.then_some(expected_targets),
811 &readback.observation.targets,
812 BevyConformanceCodeV1::TargetMismatch,
813 &mut mismatch,
814 &mut unavailable,
815 );
816 mismatch.sort();
817 mismatch.dedup();
818 unavailable.sort();
819 unavailable.dedup();
820 if mismatch.is_empty() && unavailable.is_empty() {
821 BevyConformanceV1::Exact
822 } else {
823 BevyConformanceV1::NotExact {
824 mismatch_codes: mismatch,
825 unavailable_codes: unavailable,
826 }
827 }
828}
829
830fn compare_available_prediction_facet<T: PartialEq>(
831 expected: Option<T>,
832 observed: &T,
833 mismatch_code: BevyConformanceCodeV1,
834 mismatch: &mut Vec<BevyConformanceCodeV1>,
835 unavailable: &mut Vec<BevyConformanceCodeV1>,
836) {
837 match expected {
838 Some(expected) if expected != *observed => mismatch.push(mismatch_code),
839 Some(_) => {}
840 None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
841 }
842}
843
844fn parse_label_index(value: &str, prefix: &str) -> Option<u32> {
845 value.strip_prefix(prefix)?.parse().ok()
846}
847
848fn validate_observation(value: &BevyObservationV1) -> Result<(), BevyReadbackV1Error> {
849 if [
850 value.animations.len(),
851 value.named_animation_winners.len(),
852 value.named_scene_winners.len(),
853 value.named_skin_winners.len(),
854 value.scenes.len(),
855 value.nodes.len(),
856 value.skins.len(),
857 value.inverse_bind_matrices.len(),
858 value.targets.len(),
859 value.warnings.len(),
860 ]
861 .iter()
862 .any(|n| *n > BEVY_READBACK_V1_MAX_ROWS)
863 {
864 return Err(BevyReadbackV1Error::Contract("domain row limit"));
865 }
866 if value.animations.len()
867 + value.named_animation_winners.len()
868 + value.named_scene_winners.len()
869 + value.named_skin_winners.len()
870 + value.scenes.len()
871 + value.nodes.len()
872 + value.skins.len()
873 + value.inverse_bind_matrices.len()
874 + value.targets.len()
875 + value.warnings.len()
876 > BEVY_READBACK_V1_MAX_WORK
877 {
878 return Err(BevyReadbackV1Error::Contract("aggregate work limit"));
879 }
880 for rows in [
881 &value.animations,
882 &value.scenes,
883 &value.nodes,
884 &value.skins,
885 &value.inverse_bind_matrices,
886 ] {
887 if !rows.windows(2).all(|p| p[0].index < p[1].index)
888 || rows.iter().any(|row| !safe(&row.label))
889 {
890 return Err(BevyReadbackV1Error::Contract("noncanonical indexed rows"));
891 }
892 }
893 for winners in [
894 &value.named_animation_winners,
895 &value.named_scene_winners,
896 &value.named_skin_winners,
897 ] {
898 if !winners
899 .windows(2)
900 .all(|p| (&p[0].name, p[0].index) < (&p[1].name, p[1].index))
901 || winners.iter().any(|row| !safe(&row.name))
902 {
903 return Err(BevyReadbackV1Error::Contract("noncanonical named winners"));
904 }
905 }
906 if !value
907 .targets
908 .windows(2)
909 .all(|p| (p[0].animation_index, &p[0].target_id) < (p[1].animation_index, &p[1].target_id))
910 || value.targets.iter().any(|row| !safe(&row.target_id))
911 {
912 return Err(BevyReadbackV1Error::Contract("noncanonical targets"));
913 }
914 if !value
915 .warnings
916 .windows(2)
917 .all(|p| (&p[0].target, &p[0].level) < (&p[1].target, &p[1].level))
918 || value
919 .warnings
920 .iter()
921 .any(|row| !safe(&row.target) || !safe(&row.level))
922 {
923 return Err(BevyReadbackV1Error::Contract("noncanonical warnings"));
924 }
925 if !matches!(value.terminal, BevyTerminalStateV1::Loaded)
926 && (!value.animations.is_empty()
927 || !value.named_animation_winners.is_empty()
928 || !value.named_scene_winners.is_empty()
929 || !value.named_skin_winners.is_empty()
930 || value.default_scene.is_some()
931 || !value.scenes.is_empty()
932 || !value.nodes.is_empty()
933 || !value.skins.is_empty()
934 || !value.inverse_bind_matrices.is_empty()
935 || !value.targets.is_empty())
936 {
937 return Err(BevyReadbackV1Error::Contract("failed load has inventory"));
938 }
939 Ok(())
940}
941fn validate_conformance(value: &BevyConformanceV1) -> Result<(), BevyReadbackV1Error> {
942 match value {
943 BevyConformanceV1::Exact => Ok(()),
944 BevyConformanceV1::NotExact {
945 mismatch_codes,
946 unavailable_codes,
947 } if !(mismatch_codes.is_empty() && unavailable_codes.is_empty())
948 && mismatch_codes.windows(2).all(|p| p[0] < p[1])
949 && unavailable_codes.windows(2).all(|p| p[0] < p[1]) =>
950 {
951 Ok(())
952 }
953 _ => Err(BevyReadbackV1Error::Contract("noncanonical conformance")),
954 }
955}
956fn safe(value: &str) -> bool {
957 !value.is_empty() && value.len() <= MAX_TEXT_BYTES && !value.chars().any(char::is_control)
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use crate::GltfAddressabilityV2;
964
965 fn frozen_lock_identity() -> InputIdentity {
966 InputIdentity::from_sha256_hex(BEVY_READBACK_V1_LOCK_SHA256, BEVY_READBACK_V1_LOCK_BYTES)
967 .expect("the committed lock identity is a lowercase digest")
968 }
969
970 #[test]
971 fn strict_reader_rejects_the_previous_mixed_bevy_patch_graph_by_digest_or_by_size() {
972 let previous_digest = "e3457e21695874f90110e5b93a36d7688c134a3b9259cb1dc4d2d8e5f741d1ed";
977 let previous_bytes = 86_390;
978 for (sha256, bytes) in [
979 (previous_digest, previous_bytes),
980 (previous_digest, BEVY_READBACK_V1_LOCK_BYTES),
981 (BEVY_READBACK_V1_LOCK_SHA256, previous_bytes),
982 ] {
983 let mut readback = valid_readback(false);
984 readback.harness.lock_identity = InputIdentity::from_sha256_hex(sha256, bytes)
985 .expect("both digests are lowercase hexadecimal");
986 let encoded = serde_json::to_vec(&readback).unwrap();
987 assert!(
988 matches!(
989 validate_bevy_readback_v1(encoded.as_slice()),
990 Err(BevyReadbackV1Error::Contract(
991 "invalid frozen harness tuple"
992 ))
993 ),
994 "accepted lock identity {sha256} at {bytes} bytes"
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}