diff --git a/docs/design/session-framework/state-media-v1.md b/docs/design/session-framework/state-media-v1.md index a8c7a86..677f85d 100644 --- a/docs/design/session-framework/state-media-v1.md +++ b/docs/design/session-framework/state-media-v1.md @@ -65,6 +65,21 @@ the sample position relative to the episode's configured audio origin, with inte firstSample/sampleRate. Crash restore preserves sample position under a new epoch; first chunk marks discontinuity. Within an epoch, chunks cannot overlap or go backwards. +**Amendment, 2026-09-22 (MEDIA-01).** Two readings of the paragraphs above, made explicit +because they are now enforced: + +- The bootstrap window is exactly the boundaries where `max(0, boundary - observationDelaySteps)` + is zero, that is `boundary <= observationDelaySteps`. Inside it the repeated `O[0]` is the + **same artifact**, not a fresh render of the same scene; outside it the producing boundary + advances one per step, and a frame from any other boundary -- older or newer -- is a step + failure. A producer therefore keeps a queue of `observationDelaySteps + 1` frames and nothing + more, so there is no older frame available to substitute. +- Within an epoch, `discontinuity` marks a range the stream actually skipped. The first chunk + after a restore marks it, and a later chunk may mark it when it starts past where the previous + chunk ended; a chunk that continues the previous one exactly is continuous by construction and + its flag is refused. Without that reading the restore rule is advisory, because a stream could + set the flag on every chunk and satisfy it by accident. + The environment provides **native game output**. Sensor transformations belong to the agent profile. Resizing for viewers, overlays, composition, audio mixing/resampling, encoding, browser delivery and streaming belong to the application/presentation layer. No bus or diff --git a/services/flysim/crates/fly-session/tests/media.rs b/services/flysim/crates/fly-session/tests/media.rs index 5f063fc..80308f8 100644 --- a/services/flysim/crates/fly-session/tests/media.rs +++ b/services/flysim/crates/fly-session/tests/media.rs @@ -24,7 +24,7 @@ use fly_session::media::{ }; use fly_session::phase::Phase; use fly_session::types::*; -use fly_session_types::media::{check_imported_asset, require_finite_samples}; +use fly_session_types::media::{AudioTimeline, check_imported_asset, require_finite_samples}; both_transports!( one_shared_image_reaches_both_agents_through_owned_attachments, @@ -38,6 +38,7 @@ both_transports!( one_audio_chunk_per_boundary_with_an_exact_sample_budget, required_agent_input_is_never_coalesced_while_spectator_snapshots_are, a_persistent_asset_and_a_transient_artifact_are_different_identities, + a_restored_audio_source_resumes_and_marks_the_discontinuity, ); const STEPS: u64 = 3; @@ -466,6 +467,7 @@ async fn a_persistent_asset_and_a_transient_artifact_are_different_identities(vi router.stats().sealed_artifacts == before }) .await; + assert!(registry.contains(&asset)); assert_eq!( registry.resolve(&asset).expect("still installed"), body.as_bytes(), @@ -474,6 +476,41 @@ async fn a_persistent_asset_and_a_transient_artifact_are_different_identities(vi f.shutdown().await; } +/// A restored epoch resumes the preserved sample position, and its first chunk marks the +/// discontinuity that says so. The producer and the validator agree about which epoch it is. +async fn a_restored_audio_source_resumes_and_marks_the_discontinuity(via: Via) { + let f = default_fixture(via).await; + let client = f.harness.observer().await.unwrap(); + let descriptor = fly_session::environment::CounterEnvironment::audio_descriptor(); + let step = hz(f.harness.config.step_hz).expect("a positive cadence"); + let resumed_at = 2_400; + + let mut source = AudioSource::restored_at(descriptor.clone(), resumed_at); + let (first, _first_handle) = source + .produce(&client, &step, 3) + .await + .expect("the restored source produces"); + assert_eq!(first.first_sample, resumed_at, "the sample position is preserved"); + assert!(first.discontinuity, "the first chunk after a restore marks it"); + let (second, _second_handle) = source + .produce(&client, &step, 3) + .await + .expect("the next chunk"); + assert!(!second.discontinuity, "only the first chunk of the epoch marks it"); + assert_eq!(second.first_sample, resumed_at + first.sample_frames); + + // The validator accepts exactly this sequence under a restored timeline, and refuses it + // under a fresh one: the flag is what distinguishes the two epochs. + let mut restored = AudioTimeline::restored_at(&descriptor, resumed_at); + restored.accept(&first, &descriptor).expect("the restored epoch"); + restored.accept(&second, &descriptor).expect("and its next chunk"); + let mut fresh = AudioTimeline::fresh(&descriptor, resumed_at); + fresh + .accept(&first, &descriptor) + .expect_err("a fresh episode's first chunk is not a discontinuity"); + f.shutdown().await; +} + /// The sample budget is exact when the cadence does not divide the sample rate: 8 kHz at 60 Hz /// is 133, 133, 134 and the total after three steps is exactly 400. #[test]