From c3396f5a613fc04f010f9e64d30301b6f3667210 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Mon, 25 May 2026 13:57:46 +0100 Subject: [PATCH] implements PLN-0075 RenderManager core --- .../src/services/vm_runtime/lifecycle.rs | 3 + .../src/services/vm_runtime/mod.rs | 3 + .../src/services/vm_runtime/render_manager.rs | 188 ++++++++++++++++++ .../src/services/vm_runtime/tick.rs | 3 + discussion/index.ndjson | 2 +- .../plans/PLN-0075-rendermanager-core.md | 2 +- 6 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 crates/console/prometeu-system/src/services/vm_runtime/render_manager.rs diff --git a/crates/console/prometeu-system/src/services/vm_runtime/lifecycle.rs b/crates/console/prometeu-system/src/services/vm_runtime/lifecycle.rs index f74810f8..da1da345 100644 --- a/crates/console/prometeu-system/src/services/vm_runtime/lifecycle.rs +++ b/crates/console/prometeu-system/src/services/vm_runtime/lifecycle.rs @@ -22,6 +22,7 @@ impl VirtualMachineRuntime { current_cartridge_title: String::new(), current_cartridge_app_version: String::new(), current_cartridge_app_mode: AppMode::Game, + render_manager: RenderManager::new(AppMode::Game), logs_written_this_frame: HashMap::new(), atomic_telemetry, last_crash_report: None, @@ -116,6 +117,7 @@ impl VirtualMachineRuntime { self.current_cartridge_title.clear(); self.current_cartridge_app_version.clear(); self.current_cartridge_app_mode = AppMode::Game; + self.render_manager = RenderManager::new(AppMode::Game); self.logs_written_this_frame.clear(); self.last_crash_report = None; @@ -148,6 +150,7 @@ impl VirtualMachineRuntime { self.current_cartridge_title = cartridge.title.clone(); self.current_cartridge_app_version = cartridge.app_version.clone(); self.current_cartridge_app_mode = cartridge.app_mode; + self.render_manager.set_active_app_mode(cartridge.app_mode); Ok(()) } Err(e) => { diff --git a/crates/console/prometeu-system/src/services/vm_runtime/mod.rs b/crates/console/prometeu-system/src/services/vm_runtime/mod.rs index 19e55af7..85bbf336 100644 --- a/crates/console/prometeu-system/src/services/vm_runtime/mod.rs +++ b/crates/console/prometeu-system/src/services/vm_runtime/mod.rs @@ -1,5 +1,6 @@ mod dispatch; mod lifecycle; +pub mod render_manager; #[cfg(test)] mod tests; mod tick; @@ -10,6 +11,7 @@ use prometeu_hal::app_mode::AppMode; use prometeu_hal::log::LogService; use prometeu_hal::telemetry::{AtomicTelemetry, CertificationConfig, Certifier}; use prometeu_vm::VirtualMachine; +pub use render_manager::RenderManager; use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicU32; @@ -25,6 +27,7 @@ pub struct VirtualMachineRuntime { pub current_cartridge_title: String, pub current_cartridge_app_version: String, pub current_cartridge_app_mode: AppMode, + pub render_manager: RenderManager, pub logs_written_this_frame: HashMap, pub atomic_telemetry: Arc, pub last_crash_report: Option, diff --git a/crates/console/prometeu-system/src/services/vm_runtime/render_manager.rs b/crates/console/prometeu-system/src/services/vm_runtime/render_manager.rs new file mode 100644 index 00000000..10d24bdc --- /dev/null +++ b/crates/console/prometeu-system/src/services/vm_runtime/render_manager.rs @@ -0,0 +1,188 @@ +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 }, +} + +pub trait RenderSurface { + fn consume_submission(&mut self, submission: &RenderSubmission); +} + +#[derive(Debug, Clone)] +pub struct RenderManager { + active_app_mode: AppMode, + next_frame_id: FrameId, + latest_complete_submission: Option, + transition_state: RenderTransitionState, +} + +impl RenderManager { + pub fn new(active_app_mode: AppMode) -> Self { + Self { + active_app_mode, + next_frame_id: FrameId::ZERO, + latest_complete_submission: None, + transition_state: RenderTransitionState::Idle, + } + } + + pub fn active_app_mode(&self) -> AppMode { + self.active_app_mode + } + + pub fn transition_state(&self) -> RenderTransitionState { + self.transition_state + } + + pub fn latest_complete_submission(&self) -> Option<&RenderSubmission> { + self.latest_complete_submission.as_ref() + } + + 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.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.latest_complete_submission = Some(submission); + Ok(self.latest_complete_submission.as_ref().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(&self, surface: &mut S) -> bool { + let Some(submission) = self.latest_complete_submission.as_ref() else { + return false; + }; + surface.consume_submission(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, + } + + 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 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)]); + } +} diff --git a/crates/console/prometeu-system/src/services/vm_runtime/tick.rs b/crates/console/prometeu-system/src/services/vm_runtime/tick.rs index 3456bf7a..a4e189e7 100644 --- a/crates/console/prometeu-system/src/services/vm_runtime/tick.rs +++ b/crates/console/prometeu-system/src/services/vm_runtime/tick.rs @@ -220,6 +220,9 @@ impl VirtualMachineRuntime { if run.reason == LogicalFrameEndingReason::FrameSync || run.reason == LogicalFrameEndingReason::EndOfRom { + self.render_manager.set_active_app_mode(self.current_cartridge_app_mode); + self.render_manager.close_compat_frame(); + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| hw.render_frame())) { let message = Self::host_panic_payload_to_string(payload); let report = CrashReport::VmPanic { message, pc: Some(vm.pc() as u32) }; diff --git a/discussion/index.ndjson b/discussion/index.ndjson index e6882b90..bae22d2d 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,5 +1,5 @@ {"type":"meta","next_id":{"DSC":39,"AGD":39,"DEC":31,"PLN":87,"LSN":47,"CLSN":1}} -{"type":"discussion","id":"DSC-0038","status":"in_progress","ticket":"render-frame-packet-boundary","title":"Logical Render Pipelines and Command Packets","created_at":"2026-05-25","updated_at":"2026-05-25","tags":["gfx","renderer","runtime","frame-composer","architecture","ui","pipeline"],"agendas":[{"id":"AGD-0038","file":"AGD-0038-renderframepacket-boundary-for-classic-2d-renderer.md","status":"accepted","created_at":"2026-05-25","updated_at":"2026-05-25"}],"decisions":[{"id":"DEC-0030","file":"DEC-0030-logical-render-pipeline-command-boundary.md","status":"accepted","created_at":"2026-05-25","updated_at":"2026-05-25","ref_agenda":"AGD-0038"}],"plans":[{"id":"PLN-0073","file":"PLN-0073-render-contract-specs.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0074","file":"PLN-0074-hal-render-submission-types.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0075","file":"PLN-0075-rendermanager-core.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0076","file":"PLN-0076-capabilities-and-abi-domain-split.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0077","file":"PLN-0077-composer-buffer-and-game2d-packet.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0078","file":"PLN-0078-classic2d-game-renderer-consumer.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0079","file":"PLN-0079-gfx2d-primitive-domain.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0080","file":"PLN-0080-shell-ui-packet-and-gfxui-domain.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0081","file":"PLN-0081-shell-hub-migration.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0082","file":"PLN-0082-frame-publication-and-present-boundary.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0083","file":"PLN-0083-fade-removal.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0084","file":"PLN-0084-host-debug-overlay-removal.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0085","file":"PLN-0085-pbs-stdlib-and-syscall-declarations.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0086","file":"PLN-0086-end-to-end-render-boundary-validation.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0038","status":"in_progress","ticket":"render-frame-packet-boundary","title":"Logical Render Pipelines and Command Packets","created_at":"2026-05-25","updated_at":"2026-05-25","tags":["gfx","renderer","runtime","frame-composer","architecture","ui","pipeline"],"agendas":[{"id":"AGD-0038","file":"AGD-0038-renderframepacket-boundary-for-classic-2d-renderer.md","status":"accepted","created_at":"2026-05-25","updated_at":"2026-05-25"}],"decisions":[{"id":"DEC-0030","file":"DEC-0030-logical-render-pipeline-command-boundary.md","status":"accepted","created_at":"2026-05-25","updated_at":"2026-05-25","ref_agenda":"AGD-0038"}],"plans":[{"id":"PLN-0073","file":"PLN-0073-render-contract-specs.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0074","file":"PLN-0074-hal-render-submission-types.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0075","file":"PLN-0075-rendermanager-core.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0076","file":"PLN-0076-capabilities-and-abi-domain-split.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0077","file":"PLN-0077-composer-buffer-and-game2d-packet.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0078","file":"PLN-0078-classic2d-game-renderer-consumer.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0079","file":"PLN-0079-gfx2d-primitive-domain.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0080","file":"PLN-0080-shell-ui-packet-and-gfxui-domain.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0081","file":"PLN-0081-shell-hub-migration.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0082","file":"PLN-0082-frame-publication-and-present-boundary.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0083","file":"PLN-0083-fade-removal.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0084","file":"PLN-0084-host-debug-overlay-removal.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0085","file":"PLN-0085-pbs-stdlib-and-syscall-declarations.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0086","file":"PLN-0086-end-to-end-render-boundary-validation.md","status":"open","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]}],"lessons":[]} {"type":"discussion","id":"DSC-0035","status":"done","ticket":"task-owned-shell-windows","title":"Agenda - Task-Owned Shell Windows","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","task","window-manager","shell","lifecycle"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0044","file":"discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]} {"type":"discussion","id":"DSC-0034","status":"done","ticket":"system-os-domain-facades","title":"Agenda - SystemOS Domain Facades","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","services","api-surface","lifecycle","fs"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0043","file":"discussion/lessons/DSC-0034-system-os-domain-facades/LSN-0043-systemos-domain-facades.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]} {"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["perf","runtime","telemetry"],"agendas":[{"id":"AGD-0021","file":"AGD-0021-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-full-migration-to-atomic-telemetry.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0007","file":"PLN-0007-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0028","file":"discussion/lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} diff --git a/discussion/workflow/plans/PLN-0075-rendermanager-core.md b/discussion/workflow/plans/PLN-0075-rendermanager-core.md index a2274b63..c5dba1a6 100644 --- a/discussion/workflow/plans/PLN-0075-rendermanager-core.md +++ b/discussion/workflow/plans/PLN-0075-rendermanager-core.md @@ -2,7 +2,7 @@ id: PLN-0075 ticket: render-frame-packet-boundary title: RenderManager Core -status: open +status: done created: 2026-05-25 ref_decisions: [DEC-0030] tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]