2026-06-05 09:41:09 +01:00

386 lines
12 KiB
Rust

use prometeu_hal::app_mode::AppMode;
use prometeu_hal::{
FrameId, Game2DFramePacket, RenderSubmission, RenderSubmissionPacket, ShellUiFramePacket,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderTransitionState {
Idle,
Pending { from: AppMode, to: AppMode },
}
#[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, Default)]
pub struct RenderHandoff {
pending: Option<RenderSubmission>,
last_consumed: Option<RenderSubmission>,
replaced_before_consume: u64,
discarded_pending: 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<RenderSubmission> {
self.pending.take()
}
pub fn record_consumed(&mut self, submission: RenderSubmission) {
self.last_consumed = Some(submission);
}
pub fn discard_pending(&mut self) -> Option<RenderSubmission> {
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
}
}
#[derive(Debug, Clone)]
pub struct RenderManager {
active_app_mode: AppMode,
active_policy: RenderPolicy,
next_frame_id: FrameId,
handoff: RenderHandoff,
transition_state: RenderTransitionState,
}
impl RenderManager {
pub fn new(active_app_mode: AppMode) -> Self {
Self {
active_app_mode,
active_policy: RenderPolicy::for_app_mode(active_app_mode),
next_frame_id: FrameId::ZERO,
handoff: RenderHandoff::default(),
transition_state: RenderTransitionState::Idle,
}
}
pub fn active_app_mode(&self) -> AppMode {
self.active_app_mode
}
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 discard_pending_submission(&mut self) -> Option<RenderSubmission> {
self.handoff.discard_pending()
}
pub fn set_active_app_mode(&mut self, app_mode: AppMode) {
if self.active_app_mode == app_mode {
return;
}
let previous = self.active_app_mode;
self.active_app_mode = app_mode;
self.active_policy = RenderPolicy::for_app_mode(app_mode);
self.transition_state = RenderTransitionState::Pending { from: previous, to: app_mode };
}
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)
}
(AppMode::Shell, RenderSubmissionPacket::ShellUi(packet)) => {
RenderSubmission::shell_ui(frame_id, packet)
}
(active, _) => {
return Err(RenderSubmissionError::PacketAppModeMismatch { active });
}
};
self.next_frame_id = self.next_frame_id.next();
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<S: RenderSurface>(&mut self, surface: &mut S) -> bool {
let Some(submission) = self.handoff.take_latest() else {
return false;
};
surface.consume_submission(&submission);
self.handoff.record_consumed(submission);
true
}
}
impl Default for RenderManager {
fn default() -> Self {
Self::new(AppMode::Game)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct RecordingSurface {
seen: Vec<FrameId>,
}
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);
manager.set_active_app_mode(AppMode::Shell);
assert_eq!(manager.active_app_mode(), AppMode::Shell);
assert_eq!(
manager.transition_state(),
RenderTransitionState::Pending { from: AppMode::Game, to: AppMode::Shell }
);
manager.acknowledge_transition();
assert_eq!(manager.transition_state(), RenderTransitionState::Idle);
}
#[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
);
}
}