use prometeu_hal::app_mode::AppMode; use prometeu_hal::telemetry::AtomicTelemetry; use prometeu_hal::{ FrameId, Game2DFramePacket, RenderOwnership, RenderSubmission, RenderSubmissionPacket, ShellUiFramePacket, }; use std::sync::atomic::Ordering; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderTransitionState { Idle, Pending { from: RenderOwnership, to: RenderOwnership }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderSubmissionError { PacketAppModeMismatch { active: AppMode }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderPacingPolicy { FrameScheduled, LifecycleDriven, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderExecutionPolicy { LocalSynchronous, WorkerCapableWithLocalFallback, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RenderPolicy { pub app_mode: AppMode, pub pacing: RenderPacingPolicy, pub execution: RenderExecutionPolicy, } impl RenderPolicy { pub const fn for_app_mode(app_mode: AppMode) -> Self { match app_mode { AppMode::Game => Self { app_mode, pacing: RenderPacingPolicy::FrameScheduled, execution: RenderExecutionPolicy::WorkerCapableWithLocalFallback, }, AppMode::Shell => Self { app_mode, pacing: RenderPacingPolicy::LifecycleDriven, execution: RenderExecutionPolicy::LocalSynchronous, }, } } pub const fn uses_frame_scheduler(self) -> bool { matches!(self.pacing, RenderPacingPolicy::FrameScheduled) } } pub trait RenderSurface { fn consume_submission(&mut self, submission: &RenderSubmission); } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RenderTelemetrySnapshot { pub produced_submissions: u64, pub replaced_before_consume: u64, pub consumed_submissions: u64, pub presented_frames: u64, pub repeated_presents: u64, pub render_errors: u64, pub present_errors: u64, pub stale_epoch_discards: u64, pub shutdown_discards: u64, pub last_produced_frame_id: u64, pub last_consumed_frame_id: u64, pub last_presented_frame_id: u64, pub last_dropped_frame_id: u64, pub last_error_frame_id: u64, pub active_render_epoch: u64, } #[derive(Debug, Clone, Default)] struct RenderTelemetry { snapshot: RenderTelemetrySnapshot, } impl RenderTelemetry { fn record_produced(&mut self, frame_id: FrameId) { self.snapshot.produced_submissions = self.snapshot.produced_submissions.wrapping_add(1); self.snapshot.last_produced_frame_id = frame_id.get(); } fn record_replaced_before_consume(&mut self, frame_id: FrameId) { self.snapshot.replaced_before_consume = self.snapshot.replaced_before_consume.wrapping_add(1); self.snapshot.last_dropped_frame_id = frame_id.get(); } fn record_consumed(&mut self, frame_id: FrameId) { self.snapshot.consumed_submissions = self.snapshot.consumed_submissions.wrapping_add(1); self.snapshot.last_consumed_frame_id = frame_id.get(); } fn record_presented(&mut self, frame_id: FrameId) { self.snapshot.presented_frames = self.snapshot.presented_frames.wrapping_add(1); self.snapshot.last_presented_frame_id = frame_id.get(); } fn record_repeated_present(&mut self, frame_id: FrameId) { self.snapshot.repeated_presents = self.snapshot.repeated_presents.wrapping_add(1); self.snapshot.last_presented_frame_id = frame_id.get(); } fn record_render_error(&mut self, frame_id: FrameId) { self.snapshot.render_errors = self.snapshot.render_errors.wrapping_add(1); self.snapshot.last_error_frame_id = frame_id.get(); } fn record_present_error(&mut self, frame_id: FrameId) { self.snapshot.present_errors = self.snapshot.present_errors.wrapping_add(1); self.snapshot.last_error_frame_id = frame_id.get(); } fn record_stale_epoch_discard(&mut self, frame_id: FrameId) { self.snapshot.stale_epoch_discards = self.snapshot.stale_epoch_discards.wrapping_add(1); self.snapshot.last_dropped_frame_id = frame_id.get(); } fn record_shutdown_discard(&mut self, frame_id: FrameId) { self.snapshot.shutdown_discards = self.snapshot.shutdown_discards.wrapping_add(1); self.snapshot.last_dropped_frame_id = frame_id.get(); } } #[derive(Debug, Clone, Default)] pub struct RenderHandoff { pending: Option, last_consumed: Option, replaced_before_consume: u64, discarded_pending: u64, stale_epoch_discards: u64, } impl RenderHandoff { pub fn publish(&mut self, submission: RenderSubmission) { if self.pending.replace(submission).is_some() { self.replaced_before_consume = self.replaced_before_consume.wrapping_add(1); } } pub fn pending_submission(&self) -> Option<&RenderSubmission> { self.pending.as_ref() } pub fn latest_submission(&self) -> Option<&RenderSubmission> { self.pending.as_ref().or(self.last_consumed.as_ref()) } pub fn take_latest(&mut self) -> Option { self.pending.take() } pub fn record_consumed(&mut self, submission: RenderSubmission) { self.last_consumed = Some(submission); } pub fn record_stale_epoch_discard(&mut self, submission: RenderSubmission) { self.stale_epoch_discards = self.stale_epoch_discards.wrapping_add(1); self.last_consumed = Some(submission); } pub fn discard_pending(&mut self) -> Option { let discarded = self.pending.take(); if discarded.is_some() { self.discarded_pending = self.discarded_pending.wrapping_add(1); } discarded } pub fn replaced_before_consume(&self) -> u64 { self.replaced_before_consume } pub fn discarded_pending(&self) -> u64 { self.discarded_pending } pub fn stale_epoch_discards(&self) -> u64 { self.stale_epoch_discards } } #[derive(Debug, Clone)] pub struct RenderManager { active_app_mode: AppMode, active_app_id: u32, active_ownership: RenderOwnership, active_policy: RenderPolicy, next_frame_id: FrameId, handoff: RenderHandoff, telemetry: RenderTelemetry, transition_state: RenderTransitionState, } impl RenderManager { pub fn new(active_app_mode: AppMode) -> Self { Self { active_app_mode, active_app_id: 0, active_ownership: RenderOwnership::new(0, active_app_mode, 0), active_policy: RenderPolicy::for_app_mode(active_app_mode), next_frame_id: FrameId::ZERO, handoff: RenderHandoff::default(), telemetry: RenderTelemetry::default(), transition_state: RenderTransitionState::Idle, } } pub fn active_app_mode(&self) -> AppMode { self.active_app_mode } pub fn active_ownership(&self) -> RenderOwnership { self.active_ownership } pub fn active_render_policy(&self) -> RenderPolicy { self.active_policy } pub fn policy_for_app_mode(app_mode: AppMode) -> RenderPolicy { RenderPolicy::for_app_mode(app_mode) } pub fn transition_state(&self) -> RenderTransitionState { self.transition_state } pub fn latest_complete_submission(&self) -> Option<&RenderSubmission> { self.handoff.latest_submission() } pub fn pending_submission(&self) -> Option<&RenderSubmission> { self.handoff.pending_submission() } pub fn replaced_before_consume(&self) -> u64 { self.handoff.replaced_before_consume() } pub fn discarded_pending(&self) -> u64 { self.handoff.discarded_pending() } pub fn stale_epoch_discards(&self) -> u64 { self.handoff.stale_epoch_discards() } pub fn discard_pending_submission(&mut self) -> Option { let discarded = self.handoff.discard_pending(); if let Some(submission) = discarded.as_ref() { self.telemetry.record_shutdown_discard(submission.frame_id); } discarded } pub fn render_telemetry(&self) -> RenderTelemetrySnapshot { let mut snapshot = self.telemetry.snapshot; snapshot.active_render_epoch = self.active_ownership.epoch; snapshot } pub fn sync_telemetry(&self, telemetry: &AtomicTelemetry) { let snapshot = self.render_telemetry(); telemetry.produced_submissions.store(snapshot.produced_submissions, Ordering::Relaxed); telemetry .replaced_before_consume .store(snapshot.replaced_before_consume, Ordering::Relaxed); telemetry.consumed_submissions.store(snapshot.consumed_submissions, Ordering::Relaxed); telemetry.presented_frames.store(snapshot.presented_frames, Ordering::Relaxed); telemetry.repeated_presents.store(snapshot.repeated_presents, Ordering::Relaxed); telemetry.render_errors.store(snapshot.render_errors, Ordering::Relaxed); telemetry.present_errors.store(snapshot.present_errors, Ordering::Relaxed); telemetry.stale_epoch_discards.store(snapshot.stale_epoch_discards, Ordering::Relaxed); telemetry.shutdown_discards.store(snapshot.shutdown_discards, Ordering::Relaxed); telemetry.last_produced_frame_id.store(snapshot.last_produced_frame_id, Ordering::Relaxed); telemetry.last_consumed_frame_id.store(snapshot.last_consumed_frame_id, Ordering::Relaxed); telemetry .last_presented_frame_id .store(snapshot.last_presented_frame_id, Ordering::Relaxed); telemetry.last_dropped_frame_id.store(snapshot.last_dropped_frame_id, Ordering::Relaxed); telemetry.last_error_frame_id.store(snapshot.last_error_frame_id, Ordering::Relaxed); telemetry.active_render_epoch.store(snapshot.active_render_epoch, Ordering::Relaxed); } pub fn record_repeated_present(&mut self, frame_id: FrameId) { self.telemetry.record_repeated_present(frame_id); } pub fn record_render_error(&mut self, frame_id: FrameId) { self.telemetry.record_render_error(frame_id); } pub fn record_present_error(&mut self, frame_id: FrameId) { self.telemetry.record_present_error(frame_id); } pub fn set_active_app_mode(&mut self, app_mode: AppMode) { self.transition_render_owner(app_mode, self.active_app_id); } pub fn transition_render_owner(&mut self, app_mode: AppMode, app_id: u32) { if self.active_app_mode == app_mode && self.active_app_id == app_id { return; } let previous = self.active_ownership; self.active_app_mode = app_mode; self.active_app_id = app_id; self.active_ownership = RenderOwnership::new(self.active_ownership.epoch.wrapping_add(1), app_mode, app_id); self.active_policy = RenderPolicy::for_app_mode(app_mode); self.transition_state = RenderTransitionState::Pending { from: previous, to: self.active_ownership }; } pub fn acknowledge_transition(&mut self) { self.transition_state = RenderTransitionState::Idle; } pub fn close_frame_with_packet( &mut self, packet: RenderSubmissionPacket, ) -> Result<&RenderSubmission, RenderSubmissionError> { let frame_id = self.next_frame_id; let submission = match (self.active_app_mode, packet) { (AppMode::Game, RenderSubmissionPacket::Game2D(packet)) => { RenderSubmission::game2d(frame_id, packet).with_ownership(self.active_ownership) } (AppMode::Shell, RenderSubmissionPacket::ShellUi(packet)) => { RenderSubmission::shell_ui(frame_id, packet).with_ownership(self.active_ownership) } (active, _) => { return Err(RenderSubmissionError::PacketAppModeMismatch { active }); } }; self.next_frame_id = self.next_frame_id.next(); if let Some(replaced) = self.handoff.pending_submission() { self.telemetry.record_replaced_before_consume(replaced.frame_id); } self.telemetry.record_produced(frame_id); self.handoff.publish(submission); Ok(self.handoff.pending_submission().expect("submission was just stored")) } pub fn close_compat_frame(&mut self) -> &RenderSubmission { let packet = match self.active_app_mode { AppMode::Game => RenderSubmissionPacket::Game2D(Game2DFramePacket::default()), AppMode::Shell => RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new(Vec::new())), }; self.close_frame_with_packet(packet).expect("compat packet matches active app mode") } pub fn publish_latest(&mut self, surface: &mut S) -> bool { let Some(submission) = self.handoff.take_latest() else { return false; }; if submission.ownership != self.active_ownership { self.telemetry.record_stale_epoch_discard(submission.frame_id); self.handoff.record_stale_epoch_discard(submission); return false; } self.telemetry.record_consumed(submission.frame_id); surface.consume_submission(&submission); self.telemetry.record_presented(submission.frame_id); self.handoff.record_consumed(submission); true } } impl Default for RenderManager { fn default() -> Self { Self::new(AppMode::Game) } } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use std::sync::atomic::AtomicU32; #[derive(Default)] struct RecordingSurface { seen: Vec, } impl RenderSurface for RecordingSurface { fn consume_submission(&mut self, submission: &RenderSubmission) { self.seen.push(submission.frame_id); } } #[test] fn frame_closure_assigns_monotonic_frame_ids() { let mut manager = RenderManager::new(AppMode::Game); let first = manager.close_compat_frame().frame_id; let second = manager.close_compat_frame().frame_id; assert_eq!(first, FrameId::new(0)); assert_eq!(second, FrameId::new(1)); } #[test] fn latest_complete_submission_wins_without_queue_growth() { let mut manager = RenderManager::new(AppMode::Game); manager.close_compat_frame(); manager.close_compat_frame(); let latest = manager.latest_complete_submission().expect("latest submission"); assert_eq!(latest.frame_id, FrameId::new(1)); } #[test] fn rejects_packet_that_does_not_match_active_app_mode() { let mut manager = RenderManager::new(AppMode::Game); let err = manager .close_frame_with_packet(RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new( Vec::new(), ))) .expect_err("shell packet is invalid in game mode"); assert_eq!(err, RenderSubmissionError::PacketAppModeMismatch { active: AppMode::Game }); } #[test] fn app_mode_switch_records_noop_transition_placeholder() { let mut manager = RenderManager::new(AppMode::Game); let previous = manager.active_ownership(); manager.set_active_app_mode(AppMode::Shell); assert_eq!(manager.active_app_mode(), AppMode::Shell); assert_eq!( manager.transition_state(), RenderTransitionState::Pending { from: previous, to: RenderOwnership::new(1, AppMode::Shell, 0) } ); manager.acknowledge_transition(); assert_eq!(manager.transition_state(), RenderTransitionState::Idle); } #[test] fn transition_render_owner_increments_epoch_for_same_mode_app_swap() { let mut manager = RenderManager::new(AppMode::Game); manager.transition_render_owner(AppMode::Game, 7); let first_owner = manager.active_ownership(); manager.transition_render_owner(AppMode::Game, 8); assert_eq!(first_owner, RenderOwnership::new(1, AppMode::Game, 7)); assert_eq!(manager.active_ownership(), RenderOwnership::new(2, AppMode::Game, 8)); } #[test] fn close_frame_stamps_submission_with_active_ownership() { let mut manager = RenderManager::new(AppMode::Game); manager.transition_render_owner(AppMode::Game, 42); let submission = manager.close_compat_frame(); assert_eq!(submission.ownership, RenderOwnership::new(1, AppMode::Game, 42)); } #[test] fn render_policy_maps_game_to_frame_scheduled_worker_capable() { let policy = RenderPolicy::for_app_mode(AppMode::Game); assert_eq!(policy.pacing, RenderPacingPolicy::FrameScheduled); assert_eq!(policy.execution, RenderExecutionPolicy::WorkerCapableWithLocalFallback); assert!(policy.uses_frame_scheduler()); } #[test] fn render_policy_maps_shell_to_lifecycle_driven_local_sync() { let policy = RenderPolicy::for_app_mode(AppMode::Shell); assert_eq!(policy.pacing, RenderPacingPolicy::LifecycleDriven); assert_eq!(policy.execution, RenderExecutionPolicy::LocalSynchronous); assert!(!policy.uses_frame_scheduler()); } #[test] fn active_render_policy_follows_app_mode_transition() { let mut manager = RenderManager::new(AppMode::Game); assert_eq!(manager.active_render_policy(), RenderPolicy::for_app_mode(AppMode::Game)); manager.set_active_app_mode(AppMode::Shell); assert_eq!(manager.active_render_policy(), RenderPolicy::for_app_mode(AppMode::Shell)); } #[test] fn publish_latest_hands_submission_to_surface() { let mut manager = RenderManager::new(AppMode::Game); let mut surface = RecordingSurface::default(); assert!(!manager.publish_latest(&mut surface)); manager.close_compat_frame(); assert!(manager.publish_latest(&mut surface)); assert_eq!(surface.seen, vec![FrameId::new(0)]); } #[test] fn render_handoff_takes_owned_latest_submission() { let mut handoff = RenderHandoff::default(); assert!(handoff.take_latest().is_none()); handoff.publish(RenderSubmission::game2d(FrameId::new(7), Game2DFramePacket::default())); let submission = handoff.take_latest().expect("pending submission"); assert_eq!(submission.frame_id, FrameId::new(7)); assert!(handoff.take_latest().is_none()); } #[test] fn render_handoff_counts_replacement_before_consume() { let mut handoff = RenderHandoff::default(); handoff.publish(RenderSubmission::game2d(FrameId::new(1), Game2DFramePacket::default())); handoff.publish(RenderSubmission::game2d(FrameId::new(2), Game2DFramePacket::default())); assert_eq!(handoff.replaced_before_consume(), 1); assert_eq!(handoff.take_latest().expect("latest").frame_id, FrameId::new(2)); } #[test] fn render_handoff_discards_pending_submission() { let mut handoff = RenderHandoff::default(); assert!(handoff.discard_pending().is_none()); handoff.publish(RenderSubmission::game2d(FrameId::new(3), Game2DFramePacket::default())); let discarded = handoff.discard_pending().expect("discarded pending"); assert_eq!(discarded.frame_id, FrameId::new(3)); assert_eq!(handoff.discarded_pending(), 1); assert!(handoff.take_latest().is_none()); } #[test] fn publish_latest_consumes_pending_but_keeps_observable_last_submission() { let mut manager = RenderManager::new(AppMode::Game); let mut surface = RecordingSurface::default(); manager.close_compat_frame(); assert!(manager.pending_submission().is_some()); assert!(manager.publish_latest(&mut surface)); assert!(manager.pending_submission().is_none()); assert_eq!( manager.latest_complete_submission().expect("last consumed").frame_id, FrameId::ZERO ); } #[test] fn publish_latest_discards_stale_epoch_before_present() { let mut manager = RenderManager::new(AppMode::Game); let mut surface = RecordingSurface::default(); manager.close_compat_frame(); manager.transition_render_owner(AppMode::Shell, 1); assert!(!manager.publish_latest(&mut surface)); assert!(surface.seen.is_empty()); assert_eq!(manager.stale_epoch_discards(), 1); assert_eq!( manager.latest_complete_submission().expect("stale consumed for diagnostics").ownership, RenderOwnership::new(0, AppMode::Game, 0) ); } #[test] fn render_telemetry_counts_produced_replaced_consumed_and_presented() { let mut manager = RenderManager::new(AppMode::Game); let mut surface = RecordingSurface::default(); manager.close_compat_frame(); manager.close_compat_frame(); assert!(manager.publish_latest(&mut surface)); let telemetry = manager.render_telemetry(); assert_eq!(telemetry.produced_submissions, 2); assert_eq!(telemetry.replaced_before_consume, 1); assert_eq!(telemetry.consumed_submissions, 1); assert_eq!(telemetry.presented_frames, 1); assert_eq!(telemetry.last_produced_frame_id, 1); assert_eq!(telemetry.last_dropped_frame_id, 0); assert_eq!(telemetry.last_consumed_frame_id, 1); assert_eq!(telemetry.last_presented_frame_id, 1); } #[test] fn render_telemetry_counts_stale_repeat_and_error_hooks() { let mut manager = RenderManager::new(AppMode::Game); let mut surface = RecordingSurface::default(); manager.close_compat_frame(); manager.transition_render_owner(AppMode::Shell, 1); assert!(!manager.publish_latest(&mut surface)); manager.record_repeated_present(FrameId::new(9)); manager.record_render_error(FrameId::new(10)); manager.record_present_error(FrameId::new(11)); let telemetry = manager.render_telemetry(); assert_eq!(telemetry.stale_epoch_discards, 1); assert_eq!(telemetry.repeated_presents, 1); assert_eq!(telemetry.render_errors, 1); assert_eq!(telemetry.present_errors, 1); assert_eq!(telemetry.last_presented_frame_id, 9); assert_eq!(telemetry.last_error_frame_id, 11); } #[test] fn render_telemetry_syncs_to_atomic_telemetry() { let mut manager = RenderManager::new(AppMode::Game); let mut surface = RecordingSurface::default(); let atomic = AtomicTelemetry::new(Arc::new(AtomicU32::new(0))); manager.transition_render_owner(AppMode::Game, 5); manager.close_compat_frame(); assert!(manager.publish_latest(&mut surface)); manager.sync_telemetry(&atomic); let snapshot = atomic.snapshot(); assert_eq!(snapshot.produced_submissions, 1); assert_eq!(snapshot.consumed_submissions, 1); assert_eq!(snapshot.presented_frames, 1); assert_eq!(snapshot.last_presented_frame_id, 0); assert_eq!(snapshot.active_render_epoch, 1); } }