diff --git a/crates/console/prometeu-firmware/src/firmware/firmware.rs b/crates/console/prometeu-firmware/src/firmware/firmware.rs index 03e783a5..153fb92f 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware.rs @@ -1,11 +1,14 @@ use crate::firmware::boot_target::BootTarget; -use crate::firmware::firmware_state::{FirmwareState, LoadCartridgeStep, ResetStep}; +use crate::firmware::firmware_state::{ + FirmwareState, GameRunningStep, LoadCartridgeStep, ResetStep, +}; use crate::firmware::prometeu_context::PrometeuContext; +use prometeu_hal::app_mode::AppMode; use prometeu_hal::cartridge::Cartridge; +use prometeu_hal::log::LogSource; use prometeu_hal::telemetry::CertificationConfig; use prometeu_hal::{InputSignals, RuntimePlatform}; -use prometeu_system::{PrometeuHub, SystemOS}; -use prometeu_vm::VirtualMachine; +use prometeu_system::{PrometeuHub, ResidentGameState, SystemOS}; /// PROMETEU Firmware. /// @@ -23,8 +26,6 @@ use prometeu_vm::VirtualMachine; /// 2. Delegates the logic update to the current active state. /// 3. Handles state transitions (e.g., from Loading to Playing). pub struct Firmware { - /// The execution engine (PVM) for user applications. - pub vm: VirtualMachine, /// The underlying OS services (Syscalls, Filesystem, Telemetry). pub os: SystemOS, /// The internal state of the system launcher (Hub). @@ -35,18 +36,19 @@ pub struct Firmware { pub boot_target: BootTarget, /// State-machine lifecycle tracker. state_initialized: bool, + game_input_barrier_frames: u8, } impl Firmware { /// Initializes the firmware in the `Reset` state. pub fn new(cap_config: Option) -> Self { Self { - vm: VirtualMachine::default(), os: SystemOS::new(cap_config), hub: PrometeuHub::new(), state: FirmwareState::Reset(ResetStep), boot_target: BootTarget::Hub, state_initialized: false, + game_input_barrier_frames: 0, } } @@ -55,23 +57,31 @@ impl Firmware { /// This method is called exactly once per Host frame (60Hz). /// It updates peripheral signals and delegates the logic to the current state. pub fn tick(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) { + let barriered_signals = InputSignals::default(); + let effective_signals = if self.game_input_barrier_frames > 0 { + self.game_input_barrier_frames -= 1; + &barriered_signals + } else { + signals + }; + // 0. Process asset commits at the beginning of the frame boundary. platform.assets_mut().apply_commits(); // 1. Update the peripheral state using the latest signals from the Host. // This ensures input is consistent throughout the entire update. - platform.input_mut().pad_mut().begin_frame(signals); - platform.input_mut().touch_mut().begin_frame(signals); + platform.input_mut().pad_mut().begin_frame(effective_signals); + platform.input_mut().touch_mut().begin_frame(effective_signals); // 2. State machine lifecycle management. if !self.state_initialized { - self.on_enter(signals, platform); + self.on_enter(effective_signals, platform); self.state_initialized = true; } // 3. Update the current state and check for transitions. - if let Some(next_state) = self.on_update(signals, platform) { - self.change_state(next_state, signals, platform); + if let Some(next_state) = self.on_update(effective_signals, platform) { + self.change_state(next_state, effective_signals, platform); } } @@ -82,9 +92,23 @@ impl Firmware { signals: &InputSignals, platform: &mut dyn RuntimePlatform, ) { + let new_state = match new_state { + FirmwareState::ResumeResidentGame => { + let Some(resume_state) = self.resident_game_resume_state() else { + return; + }; + resume_state + } + state => state, + }; + let entering_game_from_home = matches!(self.state, FirmwareState::HubHome(_)) + && matches!(new_state, FirmwareState::GameRunning(_)); self.on_exit(signals, platform); self.state = new_state; self.state_initialized = false; + if entering_game_from_home { + self.game_input_barrier_frames = self.game_input_barrier_frames.max(1); + } // Enter the new state immediately to avoid "empty" frames during transitions. self.on_enter(signals, platform); @@ -94,7 +118,6 @@ impl Firmware { /// Dispatches the `on_enter` event to the current state implementation. fn on_enter(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) { let mut req = PrometeuContext { - vm: &mut self.vm, os: &mut self.os, hub: &mut self.hub, boot_target: &self.boot_target, @@ -107,6 +130,7 @@ impl Firmware { FirmwareState::LaunchHub(s) => s.on_enter(&mut req), FirmwareState::HubHome(s) => s.on_enter(&mut req), FirmwareState::LoadCartridge(s) => s.on_enter(&mut req), + FirmwareState::ResumeResidentGame => {} FirmwareState::GameRunning(s) => s.on_enter(&mut req), FirmwareState::ShellRunning(s) => s.on_enter(&mut req), FirmwareState::AppCrashes(s) => s.on_enter(&mut req), @@ -121,7 +145,6 @@ impl Firmware { platform: &mut dyn RuntimePlatform, ) -> Option { let mut req = PrometeuContext { - vm: &mut self.vm, os: &mut self.os, hub: &mut self.hub, boot_target: &self.boot_target, @@ -134,6 +157,7 @@ impl Firmware { FirmwareState::LaunchHub(s) => s.on_update(&mut req), FirmwareState::HubHome(s) => s.on_update(&mut req), FirmwareState::LoadCartridge(s) => s.on_update(&mut req), + FirmwareState::ResumeResidentGame => None, FirmwareState::GameRunning(s) => s.on_update(&mut req), FirmwareState::ShellRunning(s) => s.on_update(&mut req), FirmwareState::AppCrashes(s) => s.on_update(&mut req), @@ -143,7 +167,6 @@ impl Firmware { /// Dispatches the `on_exit` event to the current state implementation. fn on_exit(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) { let mut req = PrometeuContext { - vm: &mut self.vm, os: &mut self.os, hub: &mut self.hub, boot_target: &self.boot_target, @@ -156,6 +179,7 @@ impl Firmware { FirmwareState::LaunchHub(s) => s.on_exit(&mut req), FirmwareState::HubHome(s) => s.on_exit(&mut req), FirmwareState::LoadCartridge(s) => s.on_exit(&mut req), + FirmwareState::ResumeResidentGame => {} FirmwareState::GameRunning(s) => s.on_exit(&mut req), FirmwareState::ShellRunning(s) => s.on_exit(&mut req), FirmwareState::AppCrashes(s) => s.on_exit(&mut req), @@ -166,6 +190,60 @@ impl Firmware { self.state = FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge)); self.state_initialized = false; } + + pub fn request_home_from_host(&mut self) { + self.os.info(LogSource::Pos, "Host requested Home/SystemOS".to_string()); + if let FirmwareState::GameRunning(step) = &self.state { + if let Err(error) = self.os.lifecycle().request_home_from_game(step.task_id) { + self.os.warn( + LogSource::Pos, + format!("Home/SystemOS request could not pause Game: {error:?}"), + ); + } else { + self.game_input_barrier_frames = 1; + } + } + } + + pub fn resume_resident_game_from_home(&mut self) -> bool { + let Some(state) = self.resident_game_resume_state() else { + return false; + }; + + self.state = state; + self.state_initialized = false; + true + } + + fn resident_game_resume_state(&mut self) -> Option { + let task_id = self.os.lifecycle().resident_game_task()?; + + if let Err(error) = self.os.lifecycle().resume_task(task_id) { + self.os.warn(LogSource::Pos, format!("Resident Game resume failed: {error:?}")); + return None; + } + + let app_id = self + .os + .sessions() + .vm_session_for_task(task_id) + .map(|session| session.app_id) + .unwrap_or_else(|| self.os.vm().current_app_id()); + self.os.vm().transition_render_owner(AppMode::Game, app_id); + self.game_input_barrier_frames = self.game_input_barrier_frames.max(1); + Some(FirmwareState::GameRunning(GameRunningStep::new(task_id))) + } + + pub fn game_lifecycle_audio_paused(&mut self) -> bool { + matches!( + self.os.lifecycle().resident_game().map(|game| game.state), + Some( + ResidentGameState::PauseRequested { .. } + | ResidentGameState::PausedSuspended + | ResidentGameState::ResumeRequested, + ) + ) + } } #[cfg(test)] @@ -175,6 +253,7 @@ mod tests { use prometeu_bytecode::assembler::assemble; use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl}; use prometeu_drivers::TestPlatform; + use prometeu_hal::RuntimePlatform; use prometeu_hal::app_mode::AppMode; use prometeu_hal::cartridge::AssetsPayloadSource; use prometeu_hal::color::Color; @@ -183,7 +262,10 @@ mod tests { use prometeu_system::process::ProcessState; use prometeu_system::task::{TaskId, TaskState}; use prometeu_system::windows::WindowOwner; - use prometeu_system::{CrashReport, discover_games_root}; + use prometeu_system::{ + CrashReport, ForegroundOwner, GameLifecycleEventKind, ResidentGameState, + discover_games_root, + }; use std::fs; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -302,9 +384,13 @@ mod tests { } fn valid_cartridge(app_mode: AppMode) -> Cartridge { + valid_cartridge_with(app_mode, 9, "Valid Cart") + } + + fn valid_cartridge_with(app_mode: AppMode, app_id: u32, title: &str) -> Cartridge { Cartridge { - app_id: 9, - title: "Valid Cart".into(), + app_id, + title: title.into(), app_version: "1.0.0".into(), app_mode, capabilities: caps::NONE, @@ -315,6 +401,16 @@ mod tests { } } + fn vm_session_tick_index(firmware: &mut Firmware, task_id: TaskId) -> u64 { + firmware + .os + .sessions() + .vm_session_for_task(task_id) + .expect("VM session should exist") + .runtime + .tick_index + } + fn load_shell_running_firmware() -> (Firmware, TestPlatform, InputSignals, TaskId) { let mut firmware = Firmware::new(None); let mut platform = TestPlatform::new(); @@ -429,6 +525,24 @@ mod tests { assert!(matches!(firmware.state, FirmwareState::LaunchHub(_))); } + #[test] + fn reset_clears_vm_sessions_and_runtime_identity() { + let mut firmware = Firmware::new(None); + let mut platform = TestPlatform::new(); + let signals = InputSignals::default(); + + firmware.load_cartridge(valid_cartridge(AppMode::Game)); + firmware.tick(&signals, &mut platform); + + assert_eq!(firmware.os.sessions().vm_session_count(), 1); + assert_eq!(firmware.os.vm().current_cartridge_title(), "Valid Cart"); + + firmware.change_state(FirmwareState::Reset(ResetStep), &signals, &mut platform); + + assert_eq!(firmware.os.sessions().vm_session_count(), 0); + assert_eq!(firmware.os.vm().current_cartridge_title(), ""); + } + #[test] fn load_cartridge_routes_system_apps_to_system_running() { let mut firmware = Firmware::new(None); @@ -560,6 +674,60 @@ mod tests { assert_eq!(firmware.os.windows().window_count(), 0); } + #[test] + fn hub_home_clicking_resident_game_resumes_same_task_without_reloading() { + let root = TestGameRoot::new(); + root.add_game("stress", true); + + let (mut firmware, mut platform) = hub_home_firmware(); + firmware.hub.set_game_library(discover_games_root(&root.path)); + firmware.tick(&InputSignals::default(), &mut platform); + + let launch_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() }; + firmware.tick(&launch_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let game_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task)); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended)); + + firmware.tick(&InputSignals::default(), &mut platform); + firmware.tick(&launch_click, &mut platform); + + match &firmware.state { + FirmwareState::GameRunning(step) => assert_eq!(step.task_id, game_task), + other => panic!("expected resident GameRunning state, got {:?}", other), + } + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task)); + assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(game_task)); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Foreground)); + assert_eq!( + firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::ResumeForeground) + ); + + let held_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() }; + firmware.tick(&held_click, &mut platform); + + assert!(!platform.input().pad().any()); + assert!(!platform.input().touch().f().down); + assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty()); + assert_eq!( + firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::ResumeForeground) + ); + } + #[test] fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() { let root = TestGameRoot::new(); @@ -727,6 +895,236 @@ mod tests { assert_eq!(firmware.os.windows().window_count(), 0); } + #[test] + fn host_home_request_delivers_pause_tick_before_forced_suspension() { + let mut firmware = Firmware::new(None); + let mut platform = TestPlatform::new(); + let signals = InputSignals::default(); + + firmware.load_cartridge(valid_cartridge(AppMode::Game)); + firmware.tick(&signals, &mut platform); + + let task_id = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected GameRunning state, got {:?}", other), + }; + let tick_index_before_home = firmware.os.vm().tick_index(); + let render_ownership_before_home = firmware.os.vm().active_render_ownership(); + + firmware.request_home_from_host(); + + assert!(firmware.game_lifecycle_audio_paused()); + assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(task_id)); + assert_eq!( + firmware.os.lifecycle().resident_game().map(|game| game.state), + Some(ResidentGameState::PauseRequested { remaining_ticks: 1 }) + ); + assert_eq!( + firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::Pause) + ); + + let held_signals = + InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals }; + firmware.tick(&held_signals, &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Suspended)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(task_id), + Ok(ProcessState::Suspended) + ); + assert_eq!(firmware.os.vm().tick_index(), tick_index_before_home + 1); + assert!(!platform.input().pad().any()); + assert!(!platform.input().touch().f().down); + + let render_ownership_after_home = firmware.os.vm().active_render_ownership(); + assert_eq!(render_ownership_after_home.app_mode, AppMode::Shell); + assert_eq!(render_ownership_after_home.app_id, 0); + assert!(render_ownership_after_home.epoch > render_ownership_before_home.epoch); + assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty()); + assert_eq!( + firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::Pause) + ); + + let tick_index_after_suspension = firmware.os.vm().tick_index(); + firmware.tick(&signals, &mut platform); + assert_eq!(firmware.os.vm().tick_index(), tick_index_after_suspension); + } + + #[test] + fn resident_game_resume_restores_render_owner_after_resume_event_and_barriers_input() { + let mut firmware = Firmware::new(None); + let mut platform = TestPlatform::new(); + let signals = InputSignals::default(); + + firmware.load_cartridge(valid_cartridge(AppMode::Game)); + firmware.tick(&signals, &mut platform); + + let task_id = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&signals, &mut platform); + let hub_ownership = firmware.os.vm().active_render_ownership(); + + assert!(firmware.resume_resident_game_from_home()); + assert!(matches!(firmware.state, FirmwareState::GameRunning(_))); + assert!(!firmware.game_lifecycle_audio_paused()); + assert_eq!( + firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::ResumeForeground) + ); + + let game_ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(game_ownership.app_mode, AppMode::Game); + assert_eq!(game_ownership.app_id, 9); + assert!(game_ownership.epoch > hub_ownership.epoch); + + let held_signals = + InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals }; + firmware.tick(&held_signals, &mut platform); + + assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Foreground)); + assert!(!platform.input().pad().any()); + assert!(!platform.input().touch().f().down); + assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty()); + assert_eq!( + firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::ResumeForeground) + ); + } + + #[test] + fn game_home_shell_hub_same_game_flow_is_end_to_end_deterministic() { + let mut firmware = Firmware::new(None); + let mut platform = TestPlatform::new(); + let signals = InputSignals::default(); + + firmware.load_cartridge(valid_cartridge(AppMode::Game)); + firmware.tick(&signals, &mut platform); + + let game_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&signals, &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Hub); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task)); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended)); + + firmware.tick(&signals, &mut platform); + let shell_a_click = + InputSignals { f_signal: true, x_pos: 112, y_pos: 172, ..Default::default() }; + firmware.tick(&shell_a_click, &mut platform); + + let shell_task = match &firmware.state { + FirmwareState::ShellRunning(step) => step.task_id, + other => panic!("expected ShellRunning state, got {:?}", other), + }; + assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell_task)); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task)); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended)); + + let tick_index_before_native_shell_updates = firmware.os.vm().tick_index(); + for _ in 0..61 { + firmware.tick(&signals, &mut platform); + } + + assert!(matches!(firmware.state, FirmwareState::ShellRunning(_))); + assert_eq!(firmware.os.vm().tick_index(), tick_index_before_native_shell_updates); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended)); + + let close_shell = InputSignals { start_signal: true, ..Default::default() }; + firmware.tick(&close_shell, &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Hub); + assert_eq!(firmware.os.lifecycle().task_state(shell_task), Some(TaskState::Closed)); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task)); + + assert!(firmware.resume_resident_game_from_home()); + + match &firmware.state { + FirmwareState::GameRunning(step) => assert_eq!(step.task_id, game_task), + other => panic!("expected GameRunning state, got {:?}", other), + } + assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(game_task)); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Foreground)); + assert_eq!( + firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::ResumeForeground) + ); + } + + #[test] + fn resident_game_and_vm_shell_sessions_tick_independently() { + let mut firmware = Firmware::new(None); + let mut platform = TestPlatform::new(); + let signals = InputSignals::default(); + + firmware.load_cartridge(valid_cartridge_with(AppMode::Game, 9, "Resident Game")); + firmware.tick(&signals, &mut platform); + + let game_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected GameRunning state, got {:?}", other), + }; + + firmware.tick(&signals, &mut platform); + let game_tick_before_home = vm_session_tick_index(&mut firmware, game_task); + + firmware.request_home_from_host(); + firmware.tick(&signals, &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended)); + let game_tick_after_suspend = vm_session_tick_index(&mut firmware, game_task); + assert_eq!(game_tick_after_suspend, game_tick_before_home + 1); + + firmware.load_cartridge(valid_cartridge_with(AppMode::Shell, 77, "VM Shell")); + firmware.tick(&signals, &mut platform); + + let shell_task = match &firmware.state { + FirmwareState::ShellRunning(step) => step.task_id, + other => panic!("expected ShellRunning state, got {:?}", other), + }; + + assert_ne!(shell_task, game_task); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task)); + assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended)); + + let shell_tick_before = vm_session_tick_index(&mut firmware, shell_task); + for _ in 0..3 { + firmware.tick(&signals, &mut platform); + } + + assert_eq!(vm_session_tick_index(&mut firmware, game_task), game_tick_after_suspend); + assert_eq!(vm_session_tick_index(&mut firmware, shell_task), shell_tick_before + 3); + + let close_shell = InputSignals { start_signal: true, ..Default::default() }; + firmware.tick(&close_shell, &mut platform); + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().task_state(shell_task), Some(TaskState::Closed)); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task)); + + assert!(firmware.resume_resident_game_from_home()); + firmware.tick(&signals, &mut platform); + + match &firmware.state { + FirmwareState::GameRunning(step) => assert_eq!(step.task_id, game_task), + other => panic!("expected GameRunning state, got {:?}", other), + } + assert!(vm_session_tick_index(&mut firmware, game_task) > game_tick_after_suspend); + } + #[test] fn direct_game_boot_does_not_depend_on_userland_run_cart_syscall() { assert!(Syscall::from_u32(0x0002).is_none()); diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_state.rs b/crates/console/prometeu-firmware/src/firmware/firmware_state.rs index 744ba05a..130d8812 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_state.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_state.rs @@ -14,6 +14,7 @@ pub enum FirmwareState { LaunchHub(LaunchHubStep), HubHome(HubHomeStep), LoadCartridge(LoadCartridgeStep), + ResumeResidentGame, GameRunning(GameRunningStep), ShellRunning(ShellRunningStep), AppCrashes(AppCrashesStep), diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_game_running.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_game_running.rs index 911e978b..677ea2ef 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_game_running.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_game_running.rs @@ -1,8 +1,9 @@ -use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState}; +use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState, HubHomeStep}; use crate::firmware::prometeu_context::PrometeuContext; +use prometeu_hal::app_mode::AppMode; use prometeu_hal::log::{LogLevel, LogSource}; -use prometeu_system::CrashReport; use prometeu_system::task::{TaskId, TaskState}; +use prometeu_system::{CrashReport, ResidentGameState}; #[derive(Debug, Clone)] pub struct GameRunningStep { @@ -36,13 +37,35 @@ impl GameRunningStep { return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); } - let result = ctx.os.vm().tick(ctx.vm, ctx.signals, ctx.platform); + let pause_requested = matches!( + ctx.os.lifecycle().resident_game().map(|game| game.state), + Some(ResidentGameState::PauseRequested { .. }) + ); + let result = ctx.os.vm().tick_session(self.task_id, ctx.signals, ctx.platform); if let Some(report) = result { let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report)); return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); } + if pause_requested { + match ctx.os.lifecycle().advance_game_pause_budget(self.task_id) { + Ok(true) => { + ctx.os.vm().transition_render_owner(AppMode::Shell, 0); + return Some(FirmwareState::HubHome(HubHomeStep)); + } + Ok(false) => {} + Err(_) => { + let report = CrashReport::VmPanic { + message: "failed to advance game pause budget".to_string(), + pc: None, + }; + let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report)); + return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); + } + } + } + None } diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs index 1e39275c..f00f3966 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs @@ -2,6 +2,7 @@ use crate::firmware::firmware_state::{ AppCrashesStep, FirmwareState, LoadCartridgeStep, ShellRunningStep, }; use crate::firmware::prometeu_context::PrometeuContext; +use prometeu_hal::app_mode::AppMode; use prometeu_hal::cartridge_loader::CartridgeLoader; use prometeu_hal::log::{LogLevel, LogSource}; use prometeu_hal::primitives::Rect; @@ -19,14 +20,29 @@ impl HubHomeStep { } pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option { - let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.vm, ctx.signals, ctx.platform); + let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.platform); if let Some(report) = outcome.crash { return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); } match outcome.action { Some(SystemProfileAction::LaunchNativeShell(app)) => { - let task_id = ctx.os.sessions().create_native_shell_task(app.app_id(), app.title()); + let task_id = match ctx + .os + .sessions() + .try_create_native_shell_task(app.app_id(), app.title()) + { + Ok(task_id) => task_id, + Err(error) => { + ctx.os.log( + LogLevel::Warn, + LogSource::Hub, + 0, + format!("Failed to launch native shell {}: {:?}", app.title(), error), + ); + return None; + } + }; let id = ctx.os.windows().add_window( app.title().to_string(), WindowOwner::Task(task_id), @@ -38,6 +54,32 @@ impl HubHomeStep { } Some(SystemProfileAction::LaunchGame { path }) => match CartridgeLoader::load(&path) { Ok(cartridge) => { + if cartridge.app_mode == AppMode::Game + && let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task() + { + let resident_app_id = ctx + .os + .sessions() + .vm_session_for_task(resident_task_id) + .map(|session| session.app_id) + .unwrap_or(0); + + if resident_app_id == cartridge.app_id { + return Some(FirmwareState::ResumeResidentGame); + } + + ctx.os.log( + LogLevel::Warn, + LogSource::Hub, + 0, + format!( + "Cannot launch Home game {} while resident game app_id {} is active", + cartridge.title, resident_app_id + ), + ); + return None; + } + return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge))); } Err(error) => { diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs index bb26adfd..80307186 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs @@ -14,11 +14,13 @@ use prometeu_system::windows::WindowOwner; pub struct LoadCartridgeStep { pub cartridge: Cartridge, init_error: Option, + loaded_task_id: Option, + loaded_app_mode: Option, } impl LoadCartridgeStep { pub fn new(cartridge: Cartridge) -> Self { - Self { cartridge, init_error: None } + Self { cartridge, init_error: None, loaded_task_id: None, loaded_app_mode: None } } pub fn on_enter(&mut self, ctx: &mut PrometeuContext) { @@ -36,7 +38,15 @@ impl LoadCartridgeStep { self.cartridge.assets.clone(), ); - self.init_error = ctx.os.vm().initialize(ctx.vm, &self.cartridge).err(); + match ctx.os.sessions().load_vm_cartridge(&self.cartridge) { + Ok(loaded) => { + self.loaded_task_id = Some(loaded.task_id); + self.loaded_app_mode = Some(loaded.app_mode); + } + Err(report) => { + self.init_error = Some(report.into_crash_report("load VM cartridge into session")); + } + } } pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option { @@ -44,11 +54,18 @@ impl LoadCartridgeStep { return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); } - if self.cartridge.app_mode == AppMode::Shell { - let task_id = ctx - .os - .sessions() - .create_vm_shell_task(self.cartridge.app_id, self.cartridge.title.clone()); + let Some(task_id) = self.loaded_task_id.take() else { + let report = CrashReport::VmPanic { + message: format!( + "cartridge {} was not loaded into a VM session", + self.cartridge.title + ), + pc: None, + }; + return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); + }; + + if self.loaded_app_mode == Some(AppMode::Shell) { let id = ctx.os.windows().add_window( self.cartridge.title.clone(), WindowOwner::Task(task_id), @@ -59,10 +76,6 @@ impl LoadCartridgeStep { return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id))); } - let task_id = ctx - .os - .sessions() - .create_vm_game_task(self.cartridge.app_id, self.cartridge.title.clone()); Some(FirmwareState::GameRunning(GameRunningStep::new(task_id))) } diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_reset.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_reset.rs index 586e3026..6159e2b5 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_reset.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_reset.rs @@ -9,7 +9,8 @@ pub struct ResetStep; impl ResetStep { pub fn on_enter(&mut self, ctx: &mut PrometeuContext) { ctx.os.log(LogLevel::Info, LogSource::Pos, 0, "Firmware Reset".to_string()); - ctx.os.vm().reset(ctx.vm); + ctx.os.sessions().clear_vm_sessions(); + ctx.os.vm().reset_global_runtime_state(); } pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option { diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs index b0a76f1c..d23244d5 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs @@ -1,6 +1,7 @@ use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState, HubHomeStep}; use crate::firmware::prometeu_context::PrometeuContext; use prometeu_hal::log::LogSource; +use prometeu_system::process::ProcessKind; use prometeu_system::task::{TaskId, TaskState}; use prometeu_system::{CrashReport, SystemProfileAction}; @@ -59,7 +60,36 @@ impl ShellRunningStep { } } - let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.vm, ctx.signals, ctx.platform); + let process_kind = match ctx.os.lifecycle().process_kind_for_task(self.task_id) { + Ok(process_kind) => process_kind, + Err(error) => { + let report = CrashReport::VmPanic { + message: format!( + "ShellRunningStep cannot resolve process for {:?}: {:?}", + self.task_id, error, + ), + pc: None, + }; + return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); + } + }; + + let outcome = match process_kind { + ProcessKind::VmShell => { + ctx.hub.update_vm_shell_profile(ctx.os, self.task_id, ctx.signals, ctx.platform) + } + ProcessKind::NativeShell => ctx.hub.update_native_shell_profile(ctx.os, ctx.platform), + ProcessKind::VmGame => { + let report = CrashReport::VmPanic { + message: format!( + "ShellRunningStep expected shell process for {:?}, got VmGame", + self.task_id, + ), + pc: None, + }; + return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); + } + }; if let Some(report) = outcome.crash { let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report)); diff --git a/crates/console/prometeu-firmware/src/firmware/prometeu_context.rs b/crates/console/prometeu-firmware/src/firmware/prometeu_context.rs index 9c37beed..9f7dc628 100644 --- a/crates/console/prometeu-firmware/src/firmware/prometeu_context.rs +++ b/crates/console/prometeu-firmware/src/firmware/prometeu_context.rs @@ -1,10 +1,8 @@ use crate::firmware::boot_target::BootTarget; use prometeu_hal::{InputSignals, RuntimePlatform}; use prometeu_system::{PrometeuHub, SystemOS}; -use prometeu_vm::VirtualMachine; pub struct PrometeuContext<'a> { - pub vm: &'a mut VirtualMachine, pub os: &'a mut SystemOS, pub hub: &'a mut PrometeuHub, pub boot_target: &'a BootTarget, diff --git a/crates/console/prometeu-hal/src/debugger_protocol.rs b/crates/console/prometeu-hal/src/debugger_protocol.rs index e6428be7..d718a149 100644 --- a/crates/console/prometeu-hal/src/debugger_protocol.rs +++ b/crates/console/prometeu-hal/src/debugger_protocol.rs @@ -40,7 +40,7 @@ pub enum DebugResponse { Breakpoints { pcs: Vec }, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct HandshakeCartridge { pub app_id: u32, pub title: String, diff --git a/crates/console/prometeu-system/src/lib.rs b/crates/console/prometeu-system/src/lib.rs index bdce77e5..20be73b7 100644 --- a/crates/console/prometeu-system/src/lib.rs +++ b/crates/console/prometeu-system/src/lib.rs @@ -4,7 +4,10 @@ mod programs; mod services; pub use crash_report::CrashReport; -pub use os::{LifecycleError, LifecycleOperation, SystemOS}; +pub use os::{ + DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError, + LifecycleOperation, SessionError, SystemOS, +}; pub use programs::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate}; pub use prometeu_hal::{RenderWorkerBackend, RenderWorkerFrameSink}; pub use services::async_work::{ @@ -12,6 +15,9 @@ pub use services::async_work::{ AsyncWorkJobKind, AsyncWorkJobOutcome, AsyncWorkLane, AsyncWorkLaneConfig, AsyncWorkLaneController, AsyncWorkLaneError, AsyncWorkLaneTelemetry, AsyncWorkPriority, }; +pub use services::foreground::{ + ForegroundOwner, ForegroundStack, ForegroundStackError, ResidentGame, ResidentGameState, +}; pub use services::fs; pub use services::game_library::{ GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root, @@ -23,4 +29,5 @@ pub use services::vm_runtime::{ LatestRenderFrameStore, RenderWorkerConfig, RenderWorkerController, RenderWorkerHandoff, RenderWorkerHandoffWait, RenderWorkerOwnership, VirtualMachineRuntime, }; +pub use services::vm_session; pub use services::windows; diff --git a/crates/console/prometeu-system/src/os/facades/lifecycle.rs b/crates/console/prometeu-system/src/os/facades/lifecycle.rs index 7fe687b8..680a1cc5 100644 --- a/crates/console/prometeu-system/src/os/facades/lifecycle.rs +++ b/crates/console/prometeu-system/src/os/facades/lifecycle.rs @@ -1,8 +1,14 @@ use crate::CrashReport; use crate::os::SystemOS; -use crate::os::{LifecycleError, LifecycleOperation}; -use crate::process::{ProcessId, ProcessState}; -use crate::task::{TaskId, TaskState}; +use crate::os::{ + DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError, + LifecycleOperation, +}; +use crate::process::{Process, ProcessId, ProcessKind, ProcessState}; +use crate::services::foreground::{ + ForegroundOwner, ForegroundStackError, ResidentGame, ResidentGameState, +}; +use crate::task::{TaskId, TaskKind, TaskState}; pub struct LifecycleFacade<'a> { pub(in crate::os) os: &'a mut SystemOS, @@ -14,28 +20,145 @@ impl<'a> LifecycleFacade<'a> { } pub fn process_state_for_task(&self, task_id: TaskId) -> Result { - let process_id = process_id_for_task(self.os, task_id)?; - self.os - .process_manager - .get(process_id) - .map(|process| process.state) - .ok_or(LifecycleError::ProcessNotFound(process_id)) + process_for_task(self.os, task_id).map(|process| process.state) + } + + pub fn process_kind_for_task(&self, task_id: TaskId) -> Result { + process_for_task(self.os, task_id).map(|process| process.kind) } pub fn set_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> { + match task_kind_for_task(self.os, task_id)? { + TaskKind::Game => self.set_game_foreground_task(task_id), + TaskKind::Shell => self.set_shell_foreground_task(task_id), + } + } + + pub fn set_game_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> { let process_id = process_id_for_task(self.os, task_id)?; + if task_kind_for_task(self.os, task_id)? != TaskKind::Game { + return Err(LifecycleError::InvalidTransition { + task_id, + from: self.task_state(task_id).unwrap_or(TaskState::Closed), + operation: LifecycleOperation::SetForeground, + }); + } + + self.os.foreground_stack.set_game_foreground(task_id).map_err(map_foreground_error)?; self.os.task_manager.set_foreground(task_id); self.os.process_manager.mark_running(process_id); Ok(()) } + pub fn set_shell_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> { + let process_id = process_id_for_task(self.os, task_id)?; + + if task_kind_for_task(self.os, task_id)? != TaskKind::Shell { + return Err(LifecycleError::InvalidTransition { + task_id, + from: self.task_state(task_id).unwrap_or(TaskState::Closed), + operation: LifecycleOperation::SetForeground, + }); + } + + if let Some(game_task_id) = self.os.foreground_stack.resident_game_task() + && game_task_id != task_id + && self.task_state(game_task_id) != Some(TaskState::Suspended) + { + let game_process_id = process_id_for_task(self.os, game_task_id)?; + self.os.task_manager.mark_suspended(game_task_id); + self.os.process_manager.mark_suspended(game_process_id); + } + + self.os.foreground_stack.set_shell_foreground(task_id).map_err(map_foreground_error)?; + self.os.task_manager.set_foreground(task_id); + self.os.process_manager.mark_running(process_id); + + Ok(()) + } + + pub fn foreground_owner(&self) -> ForegroundOwner { + self.os.foreground_stack.owner() + } + + pub fn resident_game(&self) -> Option { + self.os.foreground_stack.resident_game() + } + + pub fn resident_game_task(&self) -> Option { + self.os.foreground_stack.resident_game_task() + } + + pub fn request_home_from_game(&mut self, task_id: TaskId) -> Result<(), LifecycleError> { + let _process_id = process_id_for_task(self.os, task_id)?; + let previous = self.os.foreground_stack.resident_game(); + + self.os + .foreground_stack + .request_home_from_game(task_id, DEFAULT_GAME_PAUSE_BUDGET_TICKS) + .map_err(map_foreground_error)?; + + if !matches!( + previous.map(|game| game.state), + Some(ResidentGameState::PauseRequested { .. } | ResidentGameState::PausedSuspended) + ) { + self.os + .game_lifecycle_events + .push(GameLifecycleEvent { task_id, kind: GameLifecycleEventKind::Pause }); + } + + Ok(()) + } + + pub fn advance_game_pause_budget(&mut self, task_id: TaskId) -> Result { + let expired = + self.os.foreground_stack.advance_pause_budget(task_id).map_err(map_foreground_error)?; + + if expired { + let process_id = process_id_for_task(self.os, task_id)?; + self.os.task_manager.mark_suspended(task_id); + self.os.process_manager.mark_suspended(process_id); + } + + Ok(expired) + } + + pub fn pending_game_lifecycle_events(&self) -> &[GameLifecycleEvent] { + &self.os.game_lifecycle_events + } + + pub fn take_game_lifecycle_events(&mut self) -> Vec { + std::mem::take(&mut self.os.game_lifecycle_events) + } + + pub fn return_to_hub(&mut self) { + self.os.foreground_stack.return_to_hub(); + self.os.task_manager.clear_foreground(); + } + pub fn suspend_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> { let process_id = process_id_for_task(self.os, task_id)?; self.os.task_manager.mark_suspended(task_id); self.os.process_manager.mark_suspended(process_id); + match self.os.foreground_stack.owner() { + ForegroundOwner::Game(owner) if owner == task_id => { + self.os + .foreground_stack + .request_home_from_game(task_id, DEFAULT_GAME_PAUSE_BUDGET_TICKS) + .map_err(map_foreground_error)?; + self.os + .foreground_stack + .advance_pause_budget(task_id) + .map_err(map_foreground_error)?; + } + ForegroundOwner::Shell(owner) if owner == task_id => { + self.os.foreground_stack.return_to_hub(); + } + _ => {} + } Ok(()) } @@ -54,8 +177,32 @@ impl<'a> LifecycleFacade<'a> { let process_id = process_id_for_task(self.os, task_id)?; - self.os.task_manager.set_foreground(task_id); - self.os.process_manager.mark_running(process_id); + match task_kind_for_task(self.os, task_id)? { + TaskKind::Game => { + self.os + .foreground_stack + .request_resume_game(task_id) + .map_err(map_foreground_error)?; + self.os.task_manager.set_foreground(task_id); + self.os.process_manager.mark_running(process_id); + self.os.game_lifecycle_events.push(GameLifecycleEvent { + task_id, + kind: GameLifecycleEventKind::ResumeForeground, + }); + self.os + .foreground_stack + .complete_resume_game(task_id) + .map_err(map_foreground_error)?; + } + TaskKind::Shell => { + self.os + .foreground_stack + .set_shell_foreground(task_id) + .map_err(map_foreground_error)?; + self.os.task_manager.set_foreground(task_id); + self.os.process_manager.mark_running(process_id); + } + } Ok(()) } @@ -65,6 +212,12 @@ impl<'a> LifecycleFacade<'a> { self.os.task_manager.close_task(task_id); self.os.process_manager.mark_stopped(process_id); + if self.os.foreground_stack.resident_game_task() == Some(task_id) { + self.os.foreground_stack.clear_resident_game(task_id); + } else if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Shell(owner) if owner == task_id) + { + self.os.foreground_stack.return_to_hub(); + } Ok(()) } @@ -78,11 +231,22 @@ impl<'a> LifecycleFacade<'a> { self.os.task_manager.mark_crashed(task_id); self.os.process_manager.mark_crashed(process_id); + if self.os.foreground_stack.resident_game_task() == Some(task_id) { + self.os.foreground_stack.clear_resident_game(task_id); + } else if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Shell(owner) if owner == task_id) + { + self.os.foreground_stack.return_to_hub(); + } Ok(()) } } +fn task_kind_for_task(os: &SystemOS, task_id: TaskId) -> Result { + let task = os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?; + Ok(task.kind) +} + fn process_id_for_task(os: &SystemOS, task_id: TaskId) -> Result { let task = os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?; @@ -92,3 +256,22 @@ fn process_id_for_task(os: &SystemOS, task_id: TaskId) -> Result Result<&Process, LifecycleError> { + let process_id = process_id_for_task(os, task_id)?; + os.process_manager.get(process_id).ok_or(LifecycleError::ProcessNotFound(process_id)) +} + +fn map_foreground_error(error: ForegroundStackError) -> LifecycleError { + match error { + ForegroundStackError::ResidentGameAlreadyExists { existing, requested } => { + LifecycleError::ResidentGameAlreadyExists { existing, requested } + } + ForegroundStackError::ForegroundShellAlreadyExists { existing, requested } => { + LifecycleError::ForegroundShellAlreadyExists { existing, requested } + } + ForegroundStackError::TaskIsNotResidentGame(task_id) => { + LifecycleError::TaskIsNotResidentGame(task_id) + } + } +} diff --git a/crates/console/prometeu-system/src/os/facades/mod.rs b/crates/console/prometeu-system/src/os/facades/mod.rs index be54cf66..dff51238 100644 --- a/crates/console/prometeu-system/src/os/facades/mod.rs +++ b/crates/console/prometeu-system/src/os/facades/mod.rs @@ -6,6 +6,6 @@ mod window; pub use fs::FsFacade; pub use lifecycle::LifecycleFacade; -pub use sessions::SessionsFacade; +pub use sessions::{SessionError, SessionsFacade}; pub use vm::VmFacade; pub use window::WindowFacade; diff --git a/crates/console/prometeu-system/src/os/facades/sessions.rs b/crates/console/prometeu-system/src/os/facades/sessions.rs index 42f8f3fe..e88fd6b6 100644 --- a/crates/console/prometeu-system/src/os/facades/sessions.rs +++ b/crates/console/prometeu-system/src/os/facades/sessions.rs @@ -1,44 +1,209 @@ +use crate::CrashReport; +use crate::os::LifecycleError; use crate::os::SystemOS; +use crate::process::ProcessId; use crate::task::TaskId; +use crate::vm_session::{VmSession, VmSessionError}; +use prometeu_hal::app_mode::AppMode; +use prometeu_hal::cartridge::Cartridge; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LoadedVmCartridge { + pub task_id: TaskId, + pub app_mode: AppMode, + pub reused_existing_session: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionError { + Lifecycle(LifecycleError), + VmSession(VmSessionError), + TaskNotFound(TaskId), + ProcessNotFound(ProcessId), + MissingVmSession(TaskId), + Initialization(CrashReport), +} + +impl SessionError { + pub fn into_crash_report(self, operation: &str) -> CrashReport { + match self { + Self::Initialization(report) => report, + error => { + CrashReport::VmPanic { message: format!("{operation} failed: {error:?}"), pc: None } + } + } + } +} + +impl From for SessionError { + fn from(error: LifecycleError) -> Self { + Self::Lifecycle(error) + } +} + +impl From for SessionError { + fn from(error: VmSessionError) -> Self { + Self::VmSession(error) + } +} pub struct SessionsFacade<'a> { pub(in crate::os) os: &'a mut SystemOS, } impl<'a> SessionsFacade<'a> { + #[cfg(test)] pub fn create_vm_game_task(&mut self, app_id: u32, title: impl Into) -> TaskId { + self.try_create_vm_game_task(app_id, title) + .expect("test/setup VM game creation should succeed") + } + + pub fn try_create_vm_game_task( + &mut self, + app_id: u32, + title: impl Into, + ) -> Result { + if let Some(session) = self.os.vm_sessions.resident_game_for_app(app_id) { + return Ok(session.task_id); + } + let title = title.into(); let process_id = self.os.process_manager.spawn_vm_game(app_id, title.clone()); let task_id = self.os.task_manager.create_game_task(process_id, app_id, title); - self.os - .lifecycle() - .set_foreground_task(task_id) - .expect("newly created game task should have an associated process"); + self.os.lifecycle().set_game_foreground_task(task_id)?; + self.try_create_vm_session_for_task(task_id)?; - task_id + Ok(task_id) } + #[cfg(test)] pub fn create_vm_shell_task(&mut self, app_id: u32, title: impl Into) -> TaskId { + self.try_create_vm_shell_task(app_id, title) + .expect("test/setup VM shell creation should succeed") + } + + pub fn try_create_vm_shell_task( + &mut self, + app_id: u32, + title: impl Into, + ) -> Result { let title = title.into(); let process_id = self.os.process_manager.spawn_vm_shell(app_id, title.clone()); let task_id = self.os.task_manager.create_shell_task(process_id, app_id, title); - self.os - .lifecycle() - .set_foreground_task(task_id) - .expect("newly created shell task should have an associated process"); + self.os.lifecycle().set_shell_foreground_task(task_id)?; + self.try_create_vm_session_for_task(task_id)?; - task_id + Ok(task_id) } + #[cfg(test)] pub fn create_native_shell_task(&mut self, app_id: u32, title: impl Into) -> TaskId { + self.try_create_native_shell_task(app_id, title) + .expect("test/setup native shell creation should succeed") + } + + pub fn try_create_native_shell_task( + &mut self, + app_id: u32, + title: impl Into, + ) -> Result { let title = title.into(); let process_id = self.os.process_manager.spawn_native_shell(app_id, title.clone()); let task_id = self.os.task_manager.create_shell_task(process_id, app_id, title); - self.os - .lifecycle() - .set_foreground_task(task_id) - .expect("newly created native shell task should have an associated process"); + self.os.lifecycle().set_shell_foreground_task(task_id)?; - task_id + Ok(task_id) + } + + pub fn load_vm_cartridge( + &mut self, + cartridge: &Cartridge, + ) -> Result { + let existing_game_task = if cartridge.app_mode == AppMode::Game { + self.os + .vm_sessions + .resident_game_for_app(cartridge.app_id) + .map(|session| session.task_id) + } else { + None + }; + + let (task_id, reused_existing_session) = if let Some(task_id) = existing_game_task { + (task_id, true) + } else if cartridge.app_mode == AppMode::Shell { + (self.try_create_vm_shell_task(cartridge.app_id, cartridge.title.clone())?, false) + } else { + let task_id = + self.try_create_vm_game_task(cartridge.app_id, cartridge.title.clone())?; + (task_id, false) + }; + + if !reused_existing_session { + self.try_initialize_session_cartridge(task_id, cartridge)?; + } + + Ok(LoadedVmCartridge { task_id, app_mode: cartridge.app_mode, reused_existing_session }) + } + + pub fn vm_session_for_task(&self, task_id: TaskId) -> Option<&VmSession> { + self.os.vm_sessions.for_task(task_id) + } + + pub fn vm_session_for_task_mut(&mut self, task_id: TaskId) -> Option<&mut VmSession> { + self.os.vm_sessions.for_task_mut(task_id) + } + + pub fn resident_game_session_for_app(&self, app_id: u32) -> Option<&VmSession> { + self.os.vm_sessions.resident_game_for_app(app_id) + } + + pub fn vm_session_count(&self) -> usize { + self.os.vm_sessions.len() + } + + pub fn clear_vm_sessions(&mut self) { + self.os.vm_sessions.clear(); + } + + pub fn try_create_vm_session_for_task(&mut self, task_id: TaskId) -> Result<(), SessionError> { + let task = + self.os.task_manager.get(task_id).ok_or(SessionError::TaskNotFound(task_id))?.clone(); + let process = self + .os + .process_manager + .get(task.process_id) + .ok_or(SessionError::ProcessNotFound(task.process_id))? + .clone(); + + let cap_config = self.os.vm_runtime.certifier.config; + let logs_count = Arc::clone(&self.os.log_service.logs_count); + self.os + .vm_sessions + .create_for_task(&task, &process, "", Some(cap_config), logs_count) + .map(|_| ()) + .map_err(SessionError::from) + } + + pub fn try_initialize_session_cartridge( + &mut self, + task_id: TaskId, + cartridge: &Cartridge, + ) -> Result<(), SessionError> { + let session = self + .os + .vm_sessions + .for_task_mut(task_id) + .ok_or(SessionError::MissingVmSession(task_id))?; + + session.app_id = cartridge.app_id; + session.title = cartridge.title.clone(); + session.app_version = cartridge.app_version.clone(); + session.app_mode = cartridge.app_mode; + session.clear_cartridge_service_state(); + session + .runtime + .initialize_vm(&mut self.os.log_service, &mut session.vm, cartridge) + .map_err(SessionError::Initialization) } } diff --git a/crates/console/prometeu-system/src/os/facades/vm.rs b/crates/console/prometeu-system/src/os/facades/vm.rs index 5335aab6..367ac0f5 100644 --- a/crates/console/prometeu-system/src/os/facades/vm.rs +++ b/crates/console/prometeu-system/src/os/facades/vm.rs @@ -1,14 +1,15 @@ use crate::CrashReport; -use crate::os::SystemOS; +use crate::os::{GameLifecycleEvent, SystemOS}; +use crate::task::TaskId; +use crate::vm_session::VmSession; use crate::{RenderWorkerConfig, RenderWorkerController}; +use prometeu_bytecode::Value; use prometeu_hal::app_mode::AppMode; -use prometeu_hal::cartridge::Cartridge; use prometeu_hal::telemetry::{CertificationConfig, TelemetryFrame}; use prometeu_hal::{ - FrameId, InputSignals, RenderWorkerBackend, RenderWorkerError, RenderWorkerFrameSink, - RuntimePlatform, + FrameId, InputSignals, RenderOwnership, RenderWorkerBackend, RenderWorkerError, + RenderWorkerFrameSink, RuntimePlatform, }; -use prometeu_vm::VirtualMachine; use std::sync::atomic::Ordering; pub struct VmFacade<'a> { @@ -16,88 +17,216 @@ pub struct VmFacade<'a> { } impl<'a> VmFacade<'a> { - pub fn initialize( + pub fn tick_session( &mut self, - vm: &mut VirtualMachine, - cartridge: &Cartridge, - ) -> Result<(), CrashReport> { - self.os.clear_cartridge_service_state(); - self.os.vm_runtime.initialize_vm(&mut self.os.log_service, vm, cartridge) - } - - pub fn tick( - &mut self, - vm: &mut VirtualMachine, + task_id: TaskId, signals: &InputSignals, platform: &mut dyn RuntimePlatform, ) -> Option { - self.os.vm_runtime.tick( + self.deliver_pending_game_lifecycle_events_to_session(task_id); + + let Some(session) = self.os.vm_sessions.for_task_mut(task_id) else { + return Some(CrashReport::VmPanic { + message: format!("task {:?} has no VM session", task_id), + pc: None, + }); + }; + + session.runtime.tick( &mut self.os.log_service, &mut self.os.fs, - &mut self.os.fs_state, + &mut session.fs_state, &mut self.os.memcard, - &mut self.os.open_files, - &mut self.os.next_handle, - vm, + &mut session.open_files, + &mut session.next_handle, + &mut session.vm, signals, platform, ) } - pub fn reset(&mut self, vm: &mut VirtualMachine) { - self.os.vm_runtime.reset(vm); + fn deliver_pending_game_lifecycle_events_to_session(&mut self, task_id: TaskId) { + let Some(session) = self.os.vm_sessions.for_task(task_id) else { + return; + }; + + if session.runtime.current_cartridge_app_mode != AppMode::Game { + return; + } + + let mut retained = Vec::new(); + let mut delivered = Vec::new(); + + for event in self.os.game_lifecycle_events.drain(..) { + if event.task_id == task_id { + delivered.push(event); + } else { + retained.push(event); + } + } + + self.os.game_lifecycle_events = retained; + if let Some(session) = self.os.vm_sessions.for_task_mut(task_id) { + session.runtime.deliver_game_lifecycle_events(delivered); + } } - pub fn debug_step_instruction( + pub fn reset_global_runtime_state(&mut self) { + self.os.clear_cartridge_service_state(); + self.os.vm_runtime.clear_cartridge_state(); + } + + pub fn debug_step_active_session( &mut self, - vm: &mut VirtualMachine, platform: &mut dyn RuntimePlatform, ) -> Option { - self.os.vm_runtime.debug_step_instruction(&mut self.os.log_service, vm, platform) + let Some(task_id) = self.active_session_task_id() else { + return Some(CrashReport::VmPanic { + message: "debug step requested without an active VM session".to_string(), + pc: None, + }); + }; + let Some(session) = self.os.vm_sessions.for_task_mut(task_id) else { + return Some(CrashReport::VmPanic { + message: format!("active debug task {:?} has no VM session", task_id), + pc: None, + }); + }; + + session.runtime.debug_step_instruction(&mut self.os.log_service, &mut session.vm, platform) + } + + pub fn active_pc(&self) -> Option { + self.active_session().map(|session| session.vm.pc()) + } + + pub fn active_operand_stack_top(&self, n: usize) -> Vec { + self.active_session().map(|session| session.vm.operand_stack_top(n)).unwrap_or_default() + } + + pub fn insert_active_breakpoint(&mut self, pc: usize) -> bool { + if let Some(session) = self.active_session_mut() { + session.vm.insert_breakpoint(pc); + return true; + } + + false + } + + pub fn remove_active_breakpoint(&mut self, pc: usize) -> bool { + if let Some(session) = self.active_session_mut() { + session.vm.remove_breakpoint(pc); + return true; + } + + false + } + + pub fn active_breakpoints_list(&self) -> Vec { + self.active_session().map(|session| session.vm.breakpoints_list()).unwrap_or_default() } pub fn paused(&self) -> bool { - self.os.vm_runtime.paused + self.active_session() + .map(|session| session.runtime.paused) + .unwrap_or(self.os.vm_runtime.paused) } pub fn set_paused(&mut self, paused: bool) { + if let Some(session) = self.active_session_mut() { + session.runtime.paused = paused; + return; + } + self.os.vm_runtime.paused = paused; } pub fn set_inspection_active(&mut self, active: bool) { + if let Some(session) = self.active_session_mut() { + session.runtime.inspection_active = active; + return; + } + self.os.vm_runtime.inspection_active = active; } pub fn request_debug_step(&mut self) { + if let Some(session) = self.active_session_mut() { + session.runtime.debug_step_request = true; + return; + } + self.os.vm_runtime.debug_step_request = true; } pub fn debug_step_requested(&self) -> bool { - self.os.vm_runtime.debug_step_request + self.active_session() + .map(|session| session.runtime.debug_step_request) + .unwrap_or(self.os.vm_runtime.debug_step_request) } pub fn logical_frame_active(&self) -> bool { - self.os.vm_runtime.logical_frame_active + self.active_session() + .map(|session| session.runtime.logical_frame_active) + .unwrap_or(self.os.vm_runtime.logical_frame_active) + } + + pub fn delivered_game_lifecycle_events(&self) -> &[GameLifecycleEvent] { + self.active_session() + .map(|session| session.runtime.delivered_game_lifecycle_events()) + .unwrap_or_else(|| self.os.vm_runtime.delivered_game_lifecycle_events()) + } + + pub fn take_delivered_game_lifecycle_events(&mut self) -> Vec { + if let Some(session) = self.active_session_mut() { + return session.runtime.take_delivered_game_lifecycle_events(); + } + + self.os.vm_runtime.take_delivered_game_lifecycle_events() } pub fn tick_index(&self) -> u64 { - self.os.vm_runtime.tick_index + self.active_session() + .map(|session| session.runtime.tick_index) + .unwrap_or(self.os.vm_runtime.tick_index) } pub fn logical_frame_index(&self) -> u64 { - self.os.vm_runtime.logical_frame_index + self.active_session() + .map(|session| session.runtime.logical_frame_index) + .unwrap_or(self.os.vm_runtime.logical_frame_index) } pub fn last_frame_cpu_time_us(&self) -> u64 { - self.os.vm_runtime.last_frame_cpu_time_us + self.active_session() + .map(|session| session.runtime.last_frame_cpu_time_us) + .unwrap_or(self.os.vm_runtime.last_frame_cpu_time_us) } pub fn completed_logical_frames(&self) -> u64 { - self.os.vm_runtime.atomic_telemetry.completed_logical_frames.load(Ordering::Relaxed).into() + self.active_session() + .map(|session| { + session + .runtime + .atomic_telemetry + .completed_logical_frames + .load(Ordering::Relaxed) + .into() + }) + .unwrap_or_else(|| { + self.os + .vm_runtime + .atomic_telemetry + .completed_logical_frames + .load(Ordering::Relaxed) + .into() + }) } pub fn telemetry_snapshot(&self) -> TelemetryFrame { - self.os.vm_runtime.atomic_telemetry.snapshot() + self.active_session() + .map(|session| session.runtime.atomic_telemetry.snapshot()) + .unwrap_or_else(|| self.os.vm_runtime.atomic_telemetry.snapshot()) } pub fn start_render_worker(&mut self, config: RenderWorkerConfig, backend: B, sink: S) @@ -120,27 +249,72 @@ impl<'a> VmFacade<'a> { self.os.vm_runtime.record_repeated_render_worker_frame(frame_id); } + pub fn active_render_ownership(&self) -> RenderOwnership { + self.os.vm_runtime.render_manager.active_ownership() + } + + pub fn transition_render_owner(&mut self, app_mode: AppMode, app_id: u32) { + self.os.vm_runtime.render_manager.transition_render_owner(app_mode, app_id); + self.os.vm_runtime.sync_render_worker_ownership(); + self.os.vm_runtime.render_manager.sync_telemetry(&self.os.vm_runtime.atomic_telemetry); + } + pub fn cert_config(&self) -> &CertificationConfig { &self.os.vm_runtime.certifier.config } pub fn last_crash_report(&self) -> Option<&CrashReport> { - self.os.vm_runtime.last_crash_report.as_ref() + self.active_session() + .and_then(|session| session.runtime.last_crash_report.as_ref()) + .or(self.os.vm_runtime.last_crash_report.as_ref()) } pub fn current_app_id(&self) -> u32 { - self.os.vm_runtime.current_app_id + self.active_session() + .map(|session| session.runtime.current_app_id) + .unwrap_or(self.os.vm_runtime.current_app_id) } pub fn current_cartridge_title(&self) -> String { - self.os.vm_runtime.current_cartridge_title.clone() + self.active_session() + .map(|session| session.runtime.current_cartridge_title.clone()) + .unwrap_or_else(|| self.os.vm_runtime.current_cartridge_title.clone()) } pub fn current_cartridge_app_version(&self) -> String { - self.os.vm_runtime.current_cartridge_app_version.clone() + self.active_session() + .map(|session| session.runtime.current_cartridge_app_version.clone()) + .unwrap_or_else(|| self.os.vm_runtime.current_cartridge_app_version.clone()) } pub fn current_cartridge_app_mode(&self) -> AppMode { - self.os.vm_runtime.current_cartridge_app_mode + self.active_session() + .map(|session| session.runtime.current_cartridge_app_mode) + .unwrap_or(self.os.vm_runtime.current_cartridge_app_mode) + } + + fn active_session(&self) -> Option<&VmSession> { + self.os + .task_manager + .foreground_task() + .and_then(|task_id| self.os.vm_sessions.for_task(task_id)) + .or_else(|| { + self.os + .foreground_stack + .resident_game_task() + .and_then(|task_id| self.os.vm_sessions.for_task(task_id)) + }) + } + + fn active_session_mut(&mut self) -> Option<&mut VmSession> { + let task_id = self.active_session_task_id()?; + self.os.vm_sessions.for_task_mut(task_id) + } + + fn active_session_task_id(&self) -> Option { + self.os + .task_manager + .foreground_task() + .or_else(|| self.os.foreground_stack.resident_game_task()) } } diff --git a/crates/console/prometeu-system/src/os/lifecycle.rs b/crates/console/prometeu-system/src/os/lifecycle.rs index bd771d47..7e1b437c 100644 --- a/crates/console/prometeu-system/src/os/lifecycle.rs +++ b/crates/console/prometeu-system/src/os/lifecycle.rs @@ -1,6 +1,20 @@ use crate::process::ProcessId; use crate::task::{TaskId, TaskState}; +pub const DEFAULT_GAME_PAUSE_BUDGET_TICKS: u8 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GameLifecycleEventKind { + Pause, + ResumeForeground, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GameLifecycleEvent { + pub task_id: TaskId, + pub kind: GameLifecycleEventKind, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LifecycleOperation { SetForeground, @@ -15,4 +29,7 @@ pub enum LifecycleError { TaskNotFound(TaskId), ProcessNotFound(ProcessId), InvalidTransition { task_id: TaskId, from: TaskState, operation: LifecycleOperation }, + ResidentGameAlreadyExists { existing: TaskId, requested: TaskId }, + ForegroundShellAlreadyExists { existing: TaskId, requested: TaskId }, + TaskIsNotResidentGame(TaskId), } diff --git a/crates/console/prometeu-system/src/os/mod.rs b/crates/console/prometeu-system/src/os/mod.rs index a438d5cc..665ef4cc 100644 --- a/crates/console/prometeu-system/src/os/mod.rs +++ b/crates/console/prometeu-system/src/os/mod.rs @@ -2,6 +2,11 @@ mod facades; mod lifecycle; mod system_os; -pub use facades::{FsFacade, LifecycleFacade, SessionsFacade, VmFacade, WindowFacade}; -pub use lifecycle::{LifecycleError, LifecycleOperation}; +pub use facades::{ + FsFacade, LifecycleFacade, SessionError, SessionsFacade, VmFacade, WindowFacade, +}; +pub use lifecycle::{ + DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError, + LifecycleOperation, +}; pub use system_os::SystemOS; diff --git a/crates/console/prometeu-system/src/os/system_os.rs b/crates/console/prometeu-system/src/os/system_os.rs index 2b4e38ec..a39cb2d3 100644 --- a/crates/console/prometeu-system/src/os/system_os.rs +++ b/crates/console/prometeu-system/src/os/system_os.rs @@ -1,8 +1,12 @@ use crate::VirtualMachineRuntime; use crate::fs::{FsState, VirtualFS}; -use crate::os::{FsFacade, LifecycleFacade, SessionsFacade, VmFacade, WindowFacade}; +use crate::os::{ + FsFacade, GameLifecycleEvent, LifecycleFacade, SessionsFacade, VmFacade, WindowFacade, +}; use crate::process::ProcessManager; +use crate::services::foreground::ForegroundStack; use crate::services::memcard::MemcardService; +use crate::services::vm_session::VmSessionRegistry; use crate::services::windows::WindowManager; use crate::task::TaskManager; use prometeu_hal::log::{LogEvent, LogLevel, LogService, LogSource}; @@ -14,6 +18,9 @@ pub struct SystemOS { pub(super) vm_runtime: VirtualMachineRuntime, pub(super) process_manager: ProcessManager, pub(super) task_manager: TaskManager, + pub(super) foreground_stack: ForegroundStack, + pub(super) vm_sessions: VmSessionRegistry, + pub(super) game_lifecycle_events: Vec, pub(super) window_manager: WindowManager, pub(super) log_service: LogService, pub(super) fs: VirtualFS, @@ -33,6 +40,9 @@ impl SystemOS { ), process_manager: ProcessManager::new(), task_manager: TaskManager::new(), + foreground_stack: ForegroundStack::new(), + vm_sessions: VmSessionRegistry::new(), + game_lifecycle_events: Vec::new(), window_manager: WindowManager::new(), log_service, fs: VirtualFS::new(), @@ -133,15 +143,33 @@ impl SystemOS { #[cfg(test)] mod tests { use super::*; - use crate::os::{LifecycleError, LifecycleOperation}; - use crate::process::ProcessState; + use crate::os::{GameLifecycleEventKind, LifecycleError, LifecycleOperation, SessionError}; + use crate::process::{ProcessId, ProcessKind, ProcessState}; + use crate::services::foreground::{ForegroundOwner, ResidentGame, ResidentGameState}; use crate::task::{TaskId, TaskState}; + use prometeu_hal::app_mode::AppMode; + use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge}; + use prometeu_hal::syscalls::caps; fn process_state_for_task(os: &SystemOS, task_id: TaskId) -> ProcessState { let task = os.task_manager.get(task_id).expect("task should exist"); os.process_manager.get(task.process_id).expect("process should exist").state } + fn session_test_cartridge(app_mode: AppMode, app_id: u32, title: &str) -> Cartridge { + Cartridge { + app_id, + title: title.to_string(), + app_version: "1.0.0".to_string(), + app_mode, + capabilities: caps::NONE, + program: vec![], + assets: AssetsPayloadSource::empty(), + asset_table: vec![], + preload: vec![], + } + } + #[test] fn set_foreground_task_marks_task_and_process_running() { let mut os = SystemOS::new(None); @@ -157,6 +185,358 @@ mod tests { assert_eq!(process_state_for_task(&os, task_id), ProcessState::Running); } + #[test] + fn foreground_stack_starts_at_hub() { + let os = SystemOS::new(None); + + assert_eq!(os.foreground_stack.owner(), ForegroundOwner::Hub); + assert_eq!(os.foreground_stack.resident_game(), None); + } + + #[test] + fn game_session_registers_single_resident_game() { + let mut os = SystemOS::new(None); + let task_id = os.sessions().create_vm_game_task(42, "Sector Crawl"); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Game(task_id)); + assert_eq!( + os.lifecycle().resident_game(), + Some(ResidentGame { task_id, state: ResidentGameState::Foreground }) + ); + } + + #[test] + fn second_resident_game_is_rejected() { + let mut os = SystemOS::new(None); + let first = os.sessions().create_vm_game_task(42, "Sector Crawl"); + let process_id = os.process_manager.spawn_vm_game(43, "Second"); + let second = os.task_manager.create_game_task(process_id, 43, "Second"); + + assert_eq!( + os.lifecycle().set_game_foreground_task(second), + Err(LifecycleError::ResidentGameAlreadyExists { existing: first, requested: second }) + ); + } + + #[test] + fn vm_game_task_creates_vm_session() { + let mut os = SystemOS::new(None); + let task_id = os.sessions().create_vm_game_task(42, "Sector Crawl"); + + let sessions = os.sessions(); + let session = sessions.vm_session_for_task(task_id).expect("VM session should exist"); + + assert_eq!(session.task_id, task_id); + assert_eq!(session.app_id, 42); + assert_eq!(session.title, "Sector Crawl"); + assert_eq!(session.app_mode, AppMode::Game); + assert_eq!(sessions.vm_session_count(), 1); + } + + #[test] + fn vm_shell_task_creates_vm_session() { + let mut os = SystemOS::new(None); + let task_id = os.sessions().create_vm_shell_task(7, "Settings"); + + let sessions = os.sessions(); + let session = sessions.vm_session_for_task(task_id).expect("VM session should exist"); + + assert_eq!(session.task_id, task_id); + assert_eq!(session.app_id, 7); + assert_eq!(session.title, "Settings"); + assert_eq!(session.app_mode, AppMode::Shell); + assert_eq!(sessions.vm_session_count(), 1); + } + + #[test] + fn native_shell_task_does_not_create_vm_session() { + let mut os = SystemOS::new(None); + let task_id = os.sessions().create_native_shell_task(7, "Settings"); + + let sessions = os.sessions(); + + assert!(sessions.vm_session_for_task(task_id).is_none()); + assert_eq!(sessions.vm_session_count(), 0); + } + + #[test] + fn same_resident_game_app_reuses_existing_vm_session() { + let mut os = SystemOS::new(None); + let first = os.sessions().create_vm_game_task(42, "Sector Crawl"); + let second = os.sessions().create_vm_game_task(42, "Sector Crawl"); + + let sessions = os.sessions(); + + assert_eq!(second, first); + assert_eq!( + sessions.resident_game_session_for_app(42).map(|session| session.task_id), + Some(first) + ); + assert_eq!(sessions.vm_session_count(), 1); + } + + #[test] + fn vm_sessions_hold_independent_runtime_state() { + let mut os = SystemOS::new(None); + let game = os.sessions().create_vm_game_task(42, "Sector Crawl"); + os.lifecycle().request_home_from_game(game).expect("home request should succeed"); + os.lifecycle().advance_game_pause_budget(game).expect("budget should expire"); + let shell = os.sessions().create_vm_shell_task(7, "Settings"); + + { + let mut sessions = os.sessions(); + let game_session = + sessions.vm_session_for_task_mut(game).expect("game VM session should exist"); + game_session.runtime.tick_index = 100; + game_session.open_files.insert(1, "/game.dat".to_string()); + } + + { + let mut sessions = os.sessions(); + let shell_session = + sessions.vm_session_for_task_mut(shell).expect("shell VM session should exist"); + shell_session.runtime.tick_index = 5; + shell_session.open_files.insert(1, "/shell.dat".to_string()); + } + + let sessions = os.sessions(); + let game_session = + sessions.vm_session_for_task(game).expect("game VM session should exist"); + let shell_session = + sessions.vm_session_for_task(shell).expect("shell VM session should exist"); + + assert_eq!(game_session.runtime.tick_index, 100); + assert_eq!(shell_session.runtime.tick_index, 5); + assert_eq!(game_session.open_files.get(&1).map(String::as_str), Some("/game.dat")); + assert_eq!(shell_session.open_files.get(&1).map(String::as_str), Some("/shell.dat")); + } + + #[test] + fn vm_debug_operations_target_foreground_shell_before_resident_game() { + let mut os = SystemOS::new(None); + let game = os.sessions().create_vm_game_task(42, "Sector Crawl"); + os.lifecycle().request_home_from_game(game).expect("home request should succeed"); + os.lifecycle().advance_game_pause_budget(game).expect("budget should expire"); + let shell = os.sessions().create_vm_shell_task(7, "Settings"); + + { + let mut sessions = os.sessions(); + let game_session = + sessions.vm_session_for_task_mut(game).expect("game VM session should exist"); + game_session.vm.insert_breakpoint(10); + } + + assert!(os.vm().insert_active_breakpoint(20)); + assert_eq!(os.vm().active_breakpoints_list(), vec![20]); + + let sessions = os.sessions(); + assert_eq!( + sessions + .vm_session_for_task(game) + .expect("game VM session should exist") + .vm + .breakpoints_list(), + vec![10] + ); + assert_eq!( + sessions + .vm_session_for_task(shell) + .expect("shell VM session should exist") + .vm + .breakpoints_list(), + vec![20] + ); + } + + #[test] + fn home_request_notifies_game_before_budget_suspends_it() { + let mut os = SystemOS::new(None); + let task_id = os.sessions().create_vm_game_task(42, "Sector Crawl"); + + os.lifecycle().request_home_from_game(task_id).expect("home request should succeed"); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Game(task_id)); + assert_eq!( + os.lifecycle().resident_game(), + Some(ResidentGame { + task_id, + state: ResidentGameState::PauseRequested { remaining_ticks: 1 }, + }) + ); + assert_eq!( + os.lifecycle().pending_game_lifecycle_events()[0].kind, + GameLifecycleEventKind::Pause + ); + assert_eq!( + os.task_manager.get(task_id).expect("task should exist").state, + TaskState::Foreground + ); + + assert!(os.lifecycle().advance_game_pause_budget(task_id).expect("budget should advance")); + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub); + assert_eq!( + os.lifecycle().resident_game(), + Some(ResidentGame { task_id, state: ResidentGameState::PausedSuspended }) + ); + assert_eq!( + os.task_manager.get(task_id).expect("task should exist").state, + TaskState::Suspended + ); + assert_eq!(process_state_for_task(&os, task_id), ProcessState::Suspended); + } + + #[test] + fn shell_foreground_keeps_game_resident_and_marks_it_suspended() { + let mut os = SystemOS::new(None); + let game = os.sessions().create_vm_game_task(42, "Sector Crawl"); + os.lifecycle().request_home_from_game(game).expect("home request should succeed"); + os.lifecycle().advance_game_pause_budget(game).expect("budget should expire"); + let shell = os.sessions().create_vm_shell_task(7, "Settings"); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell)); + assert_eq!( + os.lifecycle().resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended }) + ); + } + + #[test] + fn vm_shell_foreground_suspends_foreground_resident_game() { + let mut os = SystemOS::new(None); + let game = os.sessions().create_vm_game_task(42, "Sector Crawl"); + + let shell = os.sessions().create_vm_shell_task(7, "Settings"); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell)); + assert_eq!( + os.lifecycle().resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended }) + ); + assert_eq!( + os.task_manager.get(game).expect("game should exist").state, + TaskState::Suspended + ); + assert_eq!(process_state_for_task(&os, game), ProcessState::Suspended); + } + + #[test] + fn native_shell_foreground_suspends_foreground_resident_game() { + let mut os = SystemOS::new(None); + let game = os.sessions().create_vm_game_task(42, "Sector Crawl"); + + let shell = os.sessions().create_native_shell_task(7, "Settings"); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell)); + assert_eq!( + os.lifecycle().resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended }) + ); + assert_eq!( + os.task_manager.get(game).expect("game should exist").state, + TaskState::Suspended + ); + assert_eq!(process_state_for_task(&os, game), ProcessState::Suspended); + } + + #[test] + fn lifecycle_exposes_process_kind_for_shell_tasks() { + let mut os = SystemOS::new(None); + let vm_shell = os.sessions().create_vm_shell_task(7, "Settings"); + os.lifecycle().close_task(vm_shell).expect("vm shell should close"); + + let native_shell = os.sessions().create_native_shell_task(8, "Files"); + + assert_eq!(os.lifecycle().process_kind_for_task(vm_shell), Ok(ProcessKind::VmShell)); + assert_eq!( + os.lifecycle().process_kind_for_task(native_shell), + Ok(ProcessKind::NativeShell) + ); + } + + #[test] + fn second_foreground_shell_is_rejected() { + let mut os = SystemOS::new(None); + let first = os.sessions().create_vm_shell_task(7, "Settings"); + let process_id = os.process_manager.spawn_vm_shell(8, "Files"); + let second = os.task_manager.create_shell_task(process_id, 8, "Files"); + + assert_eq!( + os.lifecycle().set_shell_foreground_task(second), + Err(LifecycleError::ForegroundShellAlreadyExists { + existing: first, + requested: second, + }) + ); + } + + #[test] + fn vm_shell_load_returns_typed_error_when_foreground_shell_exists() { + let mut os = SystemOS::new(None); + let first = os.sessions().create_vm_shell_task(7, "Settings"); + let cartridge = session_test_cartridge(AppMode::Shell, 8, "Files"); + + let error = os.sessions().load_vm_cartridge(&cartridge).expect_err("load should fail"); + + match error { + SessionError::Lifecycle(LifecycleError::ForegroundShellAlreadyExists { + existing, + requested, + }) => { + assert_eq!(existing, first); + assert_ne!(requested, first); + } + other => panic!("expected foreground shell lifecycle error, got {:?}", other), + } + } + + #[test] + fn vm_session_creation_returns_typed_error_for_missing_task() { + let mut os = SystemOS::new(None); + + assert_eq!( + os.sessions().try_create_vm_session_for_task(TaskId(999)), + Err(SessionError::TaskNotFound(TaskId(999))) + ); + } + + #[test] + fn vm_session_creation_returns_typed_error_for_missing_process() { + let mut os = SystemOS::new(None); + let task_id = os.task_manager.create_game_task(ProcessId(999), 42, "Broken"); + + assert_eq!( + os.sessions().try_create_vm_session_for_task(task_id), + Err(SessionError::ProcessNotFound(ProcessId(999))) + ); + } + + #[test] + fn session_cartridge_initialization_returns_typed_error_for_missing_session() { + let mut os = SystemOS::new(None); + let process_id = os.process_manager.spawn_vm_game(42, "Sector Crawl"); + let task_id = os.task_manager.create_game_task(process_id, 42, "Sector Crawl"); + let cartridge = session_test_cartridge(AppMode::Game, 42, "Sector Crawl"); + + assert_eq!( + os.sessions().try_initialize_session_cartridge(task_id, &cartridge), + Err(SessionError::MissingVmSession(task_id)) + ); + } + + #[test] + fn closing_foreground_shell_returns_to_hub() { + let mut os = SystemOS::new(None); + let shell = os.sessions().create_vm_shell_task(7, "Settings"); + + os.lifecycle().close_task(shell).expect("close should succeed"); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub); + assert_eq!( + os.task_manager.get(shell).expect("shell should remain").state, + TaskState::Closed + ); + } + #[test] fn suspend_task_marks_task_and_process_suspended() { let mut os = SystemOS::new(None); @@ -184,6 +564,10 @@ mod tests { TaskState::Foreground ); assert_eq!(process_state_for_task(&os, task_id), ProcessState::Running); + assert_eq!( + os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind), + Some(GameLifecycleEventKind::ResumeForeground) + ); } #[test] diff --git a/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs b/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs index d7bf48f9..28038739 100644 --- a/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs +++ b/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs @@ -1,3 +1,4 @@ +use crate::task::TaskId; use crate::{CrashReport, GameLibrary, SystemOS}; use prometeu_hal::color::Color; use prometeu_hal::primitives::Rect; @@ -5,7 +6,6 @@ use prometeu_hal::{ FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform, ShellUiFramePacket, }; -use prometeu_vm::VirtualMachine; use std::path::PathBuf; const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 }; @@ -174,7 +174,23 @@ impl PrometeuHub { pub fn update_shell_profile( &mut self, os: &mut SystemOS, - vm: &mut VirtualMachine, + platform: &mut dyn RuntimePlatform, + ) -> SystemProfileUpdate { + let mut action = self.gui_update(os, platform); + + if os.windows().focused_window().is_some() && platform.input().pad().start().down { + action = Some(SystemProfileAction::CloseShell); + } + + self.render(os, platform); + + SystemProfileUpdate { crash: None, action } + } + + pub fn update_vm_shell_profile( + &mut self, + os: &mut SystemOS, + task_id: TaskId, signals: &InputSignals, platform: &mut dyn RuntimePlatform, ) -> SystemProfileUpdate { @@ -185,7 +201,7 @@ impl PrometeuHub { if platform.input().pad().start().down { action = Some(SystemProfileAction::CloseShell); } else if action != Some(SystemProfileAction::CloseShell) { - crash = os.vm().tick(vm, signals, platform); + crash = os.vm().tick_session(task_id, signals, platform); } } @@ -193,6 +209,22 @@ impl PrometeuHub { SystemProfileUpdate { crash, action } } + + pub fn update_native_shell_profile( + &mut self, + os: &mut SystemOS, + platform: &mut dyn RuntimePlatform, + ) -> SystemProfileUpdate { + let mut action = self.gui_update(os, platform); + + if os.windows().focused_window().is_some() && platform.input().pad().start().down { + action = Some(SystemProfileAction::CloseShell); + } + + self.render(os, platform); + + SystemProfileUpdate { crash: None, action } + } } fn action_for_click( @@ -398,6 +430,34 @@ fn draw_buffer_text(commands: &mut Vec, x: i32, y: i32, text: &str #[cfg(test)] mod tests { use super::*; + use crate::task::TaskId; + use crate::windows::WindowOwner; + use prometeu_drivers::TestPlatform; + use prometeu_hal::{InputSignals, RuntimePlatform}; + + fn focused_native_shell_fixture( + signals: &InputSignals, + ) -> (PrometeuHub, SystemOS, TestPlatform) { + let hub = PrometeuHub::new(); + let mut os = SystemOS::new(None); + let mut platform = TestPlatform::new(); + + { + let mut windows = os.windows(); + let id = windows.add_window( + "ShellA".to_string(), + WindowOwner::Task(TaskId(7)), + SHELL_FRAME, + Color::GREEN, + ); + windows.set_focus(id); + } + + platform.input_mut().pad_mut().begin_frame(signals); + platform.input_mut().touch_mut().begin_frame(signals); + + (hub, os, platform) + } #[test] fn shell_a_button_click_emits_launch_action() { @@ -518,4 +578,35 @@ mod tests { CLOSE_BUTTON.y + CLOSE_BUTTON.h - 1 )); } + + #[test] + fn native_shell_profile_start_close_does_not_tick_vm() { + let signals = InputSignals { start_signal: true, ..Default::default() }; + let (mut hub, mut os, mut platform) = focused_native_shell_fixture(&signals); + + let tick_index_before_update = os.vm().tick_index(); + let outcome = hub.update_native_shell_profile(&mut os, &mut platform); + + assert_eq!(outcome.action, Some(SystemProfileAction::CloseShell)); + assert!(outcome.crash.is_none()); + assert_eq!(os.vm().tick_index(), tick_index_before_update); + } + + #[test] + fn native_shell_profile_close_button_does_not_tick_vm() { + let signals = InputSignals { + f_signal: true, + x_pos: CLOSE_BUTTON.x, + y_pos: CLOSE_BUTTON.y, + ..Default::default() + }; + let (mut hub, mut os, mut platform) = focused_native_shell_fixture(&signals); + + let tick_index_before_update = os.vm().tick_index(); + let outcome = hub.update_native_shell_profile(&mut os, &mut platform); + + assert_eq!(outcome.action, Some(SystemProfileAction::CloseShell)); + assert!(outcome.crash.is_none()); + assert_eq!(os.vm().tick_index(), tick_index_before_update); + } } diff --git a/crates/console/prometeu-system/src/services/foreground.rs b/crates/console/prometeu-system/src/services/foreground.rs new file mode 100644 index 00000000..a299bb87 --- /dev/null +++ b/crates/console/prometeu-system/src/services/foreground.rs @@ -0,0 +1,330 @@ +use crate::task::TaskId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForegroundOwner { + Hub, + Game(TaskId), + Shell(TaskId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResidentGameState { + Foreground, + PauseRequested { remaining_ticks: u8 }, + ResumeRequested, + PausedSuspended, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResidentGame { + pub task_id: TaskId, + pub state: ResidentGameState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForegroundStackError { + ResidentGameAlreadyExists { existing: TaskId, requested: TaskId }, + ForegroundShellAlreadyExists { existing: TaskId, requested: TaskId }, + TaskIsNotResidentGame(TaskId), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForegroundStack { + owner: ForegroundOwner, + resident_game: Option, +} + +impl Default for ForegroundStack { + fn default() -> Self { + Self { owner: ForegroundOwner::Hub, resident_game: None } + } +} + +impl ForegroundStack { + pub fn new() -> Self { + Self::default() + } + + pub fn owner(&self) -> ForegroundOwner { + self.owner + } + + pub fn resident_game(&self) -> Option { + self.resident_game + } + + pub fn resident_game_task(&self) -> Option { + self.resident_game.map(|game| game.task_id) + } + + pub fn set_game_foreground(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> { + if let Some(game) = self.resident_game + && game.task_id != task_id + { + return Err(ForegroundStackError::ResidentGameAlreadyExists { + existing: game.task_id, + requested: task_id, + }); + } + + self.resident_game = Some(ResidentGame { task_id, state: ResidentGameState::Foreground }); + self.owner = ForegroundOwner::Game(task_id); + Ok(()) + } + + pub fn request_home_from_game( + &mut self, + task_id: TaskId, + pause_budget_ticks: u8, + ) -> Result<(), ForegroundStackError> { + match self.resident_game { + Some(game) if game.task_id == task_id => { + if !matches!( + game.state, + ResidentGameState::PauseRequested { .. } | ResidentGameState::PausedSuspended + ) { + self.resident_game = Some(ResidentGame { + task_id, + state: ResidentGameState::PauseRequested { + remaining_ticks: pause_budget_ticks.max(1), + }, + }); + } + Ok(()) + } + _ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)), + } + } + + pub fn advance_pause_budget(&mut self, task_id: TaskId) -> Result { + match self.resident_game { + Some(game) + if game.task_id == task_id + && matches!( + game.state, + ResidentGameState::PauseRequested { remaining_ticks: 1 } + ) => + { + self.resident_game = + Some(ResidentGame { task_id, state: ResidentGameState::PausedSuspended }); + self.owner = ForegroundOwner::Hub; + Ok(true) + } + Some(game) + if game.task_id == task_id + && matches!(game.state, ResidentGameState::PauseRequested { .. }) => + { + let ResidentGameState::PauseRequested { remaining_ticks } = game.state else { + unreachable!(); + }; + self.resident_game = Some(ResidentGame { + task_id, + state: ResidentGameState::PauseRequested { + remaining_ticks: remaining_ticks.saturating_sub(1).max(1), + }, + }); + Ok(false) + } + Some(game) if game.task_id == task_id => Ok(false), + _ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)), + } + } + + pub fn request_resume_game(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> { + match self.resident_game { + Some(game) if game.task_id == task_id => { + self.resident_game = + Some(ResidentGame { task_id, state: ResidentGameState::ResumeRequested }); + Ok(()) + } + _ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)), + } + } + + pub fn complete_resume_game(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> { + match self.resident_game { + Some(game) if game.task_id == task_id => { + self.resident_game = + Some(ResidentGame { task_id, state: ResidentGameState::Foreground }); + self.owner = ForegroundOwner::Game(task_id); + Ok(()) + } + _ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)), + } + } + + pub fn set_shell_foreground(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> { + if let ForegroundOwner::Shell(existing) = self.owner + && existing != task_id + { + return Err(ForegroundStackError::ForegroundShellAlreadyExists { + existing, + requested: task_id, + }); + } + + if let Some(game) = self.resident_game { + self.resident_game = Some(ResidentGame { + task_id: game.task_id, + state: ResidentGameState::PausedSuspended, + }); + } + self.owner = ForegroundOwner::Shell(task_id); + Ok(()) + } + + pub fn return_to_hub(&mut self) { + self.owner = ForegroundOwner::Hub; + } + + pub fn clear_resident_game(&mut self, task_id: TaskId) { + if self.resident_game_task() == Some(task_id) { + self.resident_game = None; + } + + if self.owner == ForegroundOwner::Game(task_id) { + self.owner = ForegroundOwner::Hub; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn foreground_stack_starts_at_hub() { + let stack = ForegroundStack::new(); + + assert_eq!(stack.owner(), ForegroundOwner::Hub); + assert_eq!(stack.resident_game(), None); + } + + #[test] + fn setting_game_foreground_registers_resident_game() { + let mut stack = ForegroundStack::new(); + let game = TaskId(7); + + stack.set_game_foreground(game).expect("game should become foreground"); + + assert_eq!(stack.owner(), ForegroundOwner::Game(game)); + assert_eq!( + stack.resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::Foreground }) + ); + } + + #[test] + fn second_resident_game_is_rejected() { + let mut stack = ForegroundStack::new(); + + stack.set_game_foreground(TaskId(1)).expect("first game should be accepted"); + + assert_eq!( + stack.set_game_foreground(TaskId(2)), + Err(ForegroundStackError::ResidentGameAlreadyExists { + existing: TaskId(1), + requested: TaskId(2), + }) + ); + } + + #[test] + fn home_request_moves_resident_game_to_pause_requested() { + let mut stack = ForegroundStack::new(); + let game = TaskId(3); + + stack.set_game_foreground(game).expect("game should be foreground"); + stack.request_home_from_game(game, 2).expect("home request should pause game"); + + assert_eq!(stack.owner(), ForegroundOwner::Game(game)); + assert_eq!( + stack.resident_game(), + Some(ResidentGame { + task_id: game, + state: ResidentGameState::PauseRequested { remaining_ticks: 2 }, + }) + ); + } + + #[test] + fn pause_budget_expiry_suspends_game_and_returns_to_hub() { + let mut stack = ForegroundStack::new(); + let game = TaskId(3); + + stack.set_game_foreground(game).expect("game should be foreground"); + stack.request_home_from_game(game, 1).expect("home request should pause game"); + + assert!(stack.advance_pause_budget(game).expect("budget should advance")); + assert_eq!(stack.owner(), ForegroundOwner::Hub); + assert_eq!( + stack.resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended }) + ); + } + + #[test] + fn pause_budget_decrements_before_expiry() { + let mut stack = ForegroundStack::new(); + let game = TaskId(3); + + stack.set_game_foreground(game).expect("game should be foreground"); + stack.request_home_from_game(game, 2).expect("home request should pause game"); + + assert!(!stack.advance_pause_budget(game).expect("budget should advance")); + assert_eq!(stack.owner(), ForegroundOwner::Game(game)); + assert_eq!( + stack.resident_game(), + Some(ResidentGame { + task_id: game, + state: ResidentGameState::PauseRequested { remaining_ticks: 1 }, + }) + ); + + assert!(stack.advance_pause_budget(game).expect("budget should expire")); + assert_eq!(stack.owner(), ForegroundOwner::Hub); + assert_eq!( + stack.resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended }) + ); + } + + #[test] + fn resume_request_precedes_game_foreground_restore() { + let mut stack = ForegroundStack::new(); + let game = TaskId(3); + + stack.set_game_foreground(game).expect("game should be foreground"); + stack.request_home_from_game(game, 1).expect("home request should pause game"); + stack.advance_pause_budget(game).expect("budget should expire"); + + stack.request_resume_game(game).expect("resume should be requested"); + assert_eq!(stack.owner(), ForegroundOwner::Hub); + assert_eq!( + stack.resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::ResumeRequested }) + ); + + stack.complete_resume_game(game).expect("resume should complete"); + assert_eq!(stack.owner(), ForegroundOwner::Game(game)); + assert_eq!( + stack.resident_game(), + Some(ResidentGame { task_id: game, state: ResidentGameState::Foreground }) + ); + } + + #[test] + fn shell_foreground_keeps_resident_game() { + let mut stack = ForegroundStack::new(); + let game = TaskId(3); + let shell = TaskId(4); + + stack.set_game_foreground(game).expect("game should be foreground"); + stack.request_home_from_game(game, 1).expect("home request should suspend game"); + stack.advance_pause_budget(game).expect("budget should expire"); + stack.set_shell_foreground(shell).expect("shell should become foreground"); + + assert_eq!(stack.owner(), ForegroundOwner::Shell(shell)); + assert_eq!(stack.resident_game_task(), Some(game)); + } +} diff --git a/crates/console/prometeu-system/src/services/mod.rs b/crates/console/prometeu-system/src/services/mod.rs index 1af5a6c7..aa7496a1 100644 --- a/crates/console/prometeu-system/src/services/mod.rs +++ b/crates/console/prometeu-system/src/services/mod.rs @@ -1,8 +1,10 @@ pub mod async_work; +pub mod foreground; pub mod fs; pub mod game_library; pub mod memcard; pub mod process; pub mod task; pub mod vm_runtime; +pub mod vm_session; pub mod windows; 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 a2c5ee2a..404bf9b1 100644 --- a/crates/console/prometeu-system/src/services/vm_runtime/lifecycle.rs +++ b/crates/console/prometeu-system/src/services/vm_runtime/lifecycle.rs @@ -32,6 +32,7 @@ impl VirtualMachineRuntime { gfx2d_commands: Vec::new(), gfxui_commands: Vec::new(), logs_written_this_frame: HashMap::new(), + game_lifecycle_events_delivered: Vec::new(), atomic_telemetry, last_crash_report: None, certifier: Certifier::new(cap_config.unwrap_or_default()), @@ -135,6 +136,7 @@ impl VirtualMachineRuntime { self.gfx2d_commands.clear(); self.gfxui_commands.clear(); self.logs_written_this_frame.clear(); + self.game_lifecycle_events_delivered.clear(); self.last_crash_report = None; @@ -191,6 +193,21 @@ impl VirtualMachineRuntime { } } + pub fn deliver_game_lifecycle_events(&mut self, events: I) + where + I: IntoIterator, + { + self.game_lifecycle_events_delivered.extend(events); + } + + pub fn delivered_game_lifecycle_events(&self) -> &[GameLifecycleEvent] { + &self.game_lifecycle_events_delivered + } + + pub fn take_delivered_game_lifecycle_events(&mut self) -> Vec { + std::mem::take(&mut self.game_lifecycle_events_delivered) + } + pub fn initialize_vm( &mut self, log_service: &mut LogService, @@ -224,3 +241,26 @@ impl VirtualMachineRuntime { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::os::{GameLifecycleEvent, GameLifecycleEventKind}; + use crate::task::TaskId; + + #[test] + fn vm_runtime_records_and_drains_delivered_game_lifecycle_events() { + let mut runtime = VirtualMachineRuntime::new(None); + let pause = GameLifecycleEvent { task_id: TaskId(7), kind: GameLifecycleEventKind::Pause }; + let resume = GameLifecycleEvent { + task_id: TaskId(7), + kind: GameLifecycleEventKind::ResumeForeground, + }; + + runtime.deliver_game_lifecycle_events([pause, resume]); + + assert_eq!(runtime.delivered_game_lifecycle_events(), &[pause, resume]); + assert_eq!(runtime.take_delivered_game_lifecycle_events(), vec![pause, resume]); + assert!(runtime.delivered_game_lifecycle_events().is_empty()); + } +} 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 6db48101..7f1cf23c 100644 --- a/crates/console/prometeu-system/src/services/vm_runtime/mod.rs +++ b/crates/console/prometeu-system/src/services/vm_runtime/mod.rs @@ -13,6 +13,7 @@ mod tests; mod tick; use crate::CrashReport; +use crate::os::GameLifecycleEvent; pub use frame_scheduler::FrameScheduler; use prometeu_bytecode::string_materialization_count; use prometeu_hal::app_mode::AppMode; @@ -49,6 +50,7 @@ pub struct VirtualMachineRuntime { pub gfx2d_commands: Vec, pub gfxui_commands: Vec, pub logs_written_this_frame: HashMap, + pub game_lifecycle_events_delivered: Vec, pub atomic_telemetry: Arc, pub last_crash_report: Option, pub certifier: Certifier, diff --git a/crates/console/prometeu-system/src/services/vm_session.rs b/crates/console/prometeu-system/src/services/vm_session.rs new file mode 100644 index 00000000..caa023f3 --- /dev/null +++ b/crates/console/prometeu-system/src/services/vm_session.rs @@ -0,0 +1,281 @@ +use crate::VirtualMachineRuntime; +use crate::fs::FsState; +use crate::process::{Process, ProcessId, ProcessKind}; +use crate::task::{Task, TaskId, TaskKind}; +use prometeu_hal::app_mode::AppMode; +use prometeu_hal::telemetry::CertificationConfig; +use prometeu_vm::VirtualMachine; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicU32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct VmSessionId(pub TaskId); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VmSessionError { + SessionAlreadyExists(TaskId), + TaskProcessMismatch { task_id: TaskId, task_process: ProcessId, process_id: ProcessId }, + TaskKindMismatch { task_id: TaskId, task_kind: TaskKind, process_kind: ProcessKind }, + ProcessIsNotVmBacked(ProcessId), +} + +pub struct VmSession { + pub id: VmSessionId, + pub task_id: TaskId, + pub process_id: ProcessId, + pub app_id: u32, + pub title: String, + pub app_version: String, + pub app_mode: AppMode, + pub vm: VirtualMachine, + pub runtime: VirtualMachineRuntime, + pub fs_state: FsState, + pub open_files: HashMap, + pub next_handle: u32, +} + +impl VmSession { + fn new( + task: &Task, + process: &Process, + app_mode: AppMode, + app_version: impl Into, + cap_config: Option, + logs_count: Arc, + ) -> Self { + Self { + id: VmSessionId(task.id), + task_id: task.id, + process_id: process.id, + app_id: task.app_id, + title: task.title.clone(), + app_version: app_version.into(), + app_mode, + vm: VirtualMachine::default(), + runtime: VirtualMachineRuntime::new_with_log_counter(cap_config, logs_count), + fs_state: FsState::Unmounted, + open_files: HashMap::new(), + next_handle: 1, + } + } + + pub fn clear_cartridge_service_state(&mut self) { + self.open_files.clear(); + self.next_handle = 1; + } +} + +#[derive(Default)] +pub struct VmSessionRegistry { + sessions: HashMap, +} + +impl VmSessionRegistry { + pub fn new() -> Self { + Self { sessions: HashMap::new() } + } + + pub fn create_for_task( + &mut self, + task: &Task, + process: &Process, + app_version: impl Into, + cap_config: Option, + logs_count: Arc, + ) -> Result { + if task.process_id != process.id { + return Err(VmSessionError::TaskProcessMismatch { + task_id: task.id, + task_process: task.process_id, + process_id: process.id, + }); + } + + let app_mode = app_mode_for_task_and_process(task, process)?; + let id = VmSessionId(task.id); + if self.sessions.contains_key(&id) { + return Err(VmSessionError::SessionAlreadyExists(task.id)); + } + + self.sessions.insert( + id, + VmSession::new(task, process, app_mode, app_version, cap_config, logs_count), + ); + Ok(id) + } + + pub fn get(&self, id: VmSessionId) -> Option<&VmSession> { + self.sessions.get(&id) + } + + pub fn get_mut(&mut self, id: VmSessionId) -> Option<&mut VmSession> { + self.sessions.get_mut(&id) + } + + pub fn for_task(&self, task_id: TaskId) -> Option<&VmSession> { + self.get(VmSessionId(task_id)) + } + + pub fn for_task_mut(&mut self, task_id: TaskId) -> Option<&mut VmSession> { + self.get_mut(VmSessionId(task_id)) + } + + pub fn resident_game(&self) -> Option<&VmSession> { + self.sessions.values().find(|session| session.app_mode == AppMode::Game) + } + + pub fn resident_game_for_app(&self, app_id: u32) -> Option<&VmSession> { + self.sessions + .values() + .find(|session| session.app_mode == AppMode::Game && session.app_id == app_id) + } + + pub fn len(&self) -> usize { + self.sessions.len() + } + + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + pub fn clear(&mut self) { + self.sessions.clear(); + } +} + +fn app_mode_for_task_and_process( + task: &Task, + process: &Process, +) -> Result { + match (task.kind, process.kind) { + (TaskKind::Game, ProcessKind::VmGame) => Ok(AppMode::Game), + (TaskKind::Shell, ProcessKind::VmShell) => Ok(AppMode::Shell), + (_, ProcessKind::NativeShell) => Err(VmSessionError::ProcessIsNotVmBacked(process.id)), + _ => Err(VmSessionError::TaskKindMismatch { + task_id: task.id, + task_kind: task.kind, + process_kind: process.kind, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::process::Process; + use crate::task::Task; + + #[test] + fn creates_vm_game_session_for_vm_game_task() { + let process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame); + let task = Task::new(TaskId(7), process.id, 42, "Stress", TaskKind::Game); + let mut registry = VmSessionRegistry::new(); + + let id = registry + .create_for_task(&task, &process, "", None, Arc::new(AtomicU32::new(0))) + .expect("session should create"); + let session = registry.get(id).expect("session should exist"); + + assert_eq!(session.id, VmSessionId(task.id)); + assert_eq!(session.task_id, task.id); + assert_eq!(session.process_id, process.id); + assert_eq!(session.app_id, 42); + assert_eq!(session.title, "Stress"); + assert_eq!(session.app_mode, AppMode::Game); + assert_eq!(session.runtime.current_app_id, 0); + assert_eq!(session.fs_state, FsState::Unmounted); + assert_eq!(session.next_handle, 1); + } + + #[test] + fn creates_vm_shell_session_for_vm_shell_task() { + let process = Process::new(ProcessId(1), 7, "Shell", ProcessKind::VmShell); + let task = Task::new(TaskId(3), process.id, 7, "Shell", TaskKind::Shell); + let mut registry = VmSessionRegistry::new(); + + let id = registry + .create_for_task(&task, &process, "1.0", None, Arc::new(AtomicU32::new(0))) + .expect("session should create"); + let session = registry.get(id).expect("session should exist"); + + assert_eq!(session.app_mode, AppMode::Shell); + assert_eq!(session.app_version, "1.0"); + } + + #[test] + fn rejects_native_shell_process() { + let process = Process::new(ProcessId(1), 7, "Shell", ProcessKind::NativeShell); + let task = Task::new(TaskId(3), process.id, 7, "Shell", TaskKind::Shell); + let mut registry = VmSessionRegistry::new(); + + assert_eq!( + registry.create_for_task(&task, &process, "", None, Arc::new(AtomicU32::new(0))), + Err(VmSessionError::ProcessIsNotVmBacked(process.id)) + ); + } + + #[test] + fn finds_resident_game_by_app_id() { + let process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame); + let task = Task::new(TaskId(7), process.id, 42, "Stress", TaskKind::Game); + let mut registry = VmSessionRegistry::new(); + registry + .create_for_task(&task, &process, "", None, Arc::new(AtomicU32::new(0))) + .expect("session should create"); + + assert_eq!( + registry.resident_game_for_app(42).map(|session| session.task_id), + Some(task.id) + ); + assert_eq!(registry.resident_game_for_app(43).map(|session| session.task_id), None); + } + + #[test] + fn session_runtime_state_is_independent_per_session() { + let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame); + let game_task = Task::new(TaskId(7), game_process.id, 42, "Stress", TaskKind::Game); + let shell_process = Process::new(ProcessId(2), 9, "Shell", ProcessKind::VmShell); + let shell_task = Task::new(TaskId(8), shell_process.id, 9, "Shell", TaskKind::Shell); + let mut registry = VmSessionRegistry::new(); + + registry + .create_for_task(&game_task, &game_process, "", None, Arc::new(AtomicU32::new(0))) + .expect("game session should create"); + registry + .create_for_task(&shell_task, &shell_process, "", None, Arc::new(AtomicU32::new(0))) + .expect("shell session should create"); + + registry + .for_task_mut(game_task.id) + .expect("game session should exist") + .runtime + .tick_index = 11; + registry + .for_task_mut(shell_task.id) + .expect("shell session should exist") + .runtime + .tick_index = 3; + registry + .for_task_mut(game_task.id) + .expect("game session should exist") + .open_files + .insert(1, "/game.save".to_string()); + + assert_eq!( + registry.for_task(game_task.id).map(|session| session.runtime.tick_index), + Some(11) + ); + assert_eq!( + registry.for_task(shell_task.id).map(|session| session.runtime.tick_index), + Some(3) + ); + assert!( + registry + .for_task(shell_task.id) + .expect("shell session should exist") + .open_files + .is_empty() + ); + } +} diff --git a/crates/host/prometeu-host-desktop-winit/src/debugger.rs b/crates/host/prometeu-host-desktop-winit/src/debugger.rs index 9bb1ea92..fac30e51 100644 --- a/crates/host/prometeu-host-desktop-winit/src/debugger.rs +++ b/crates/host/prometeu-host-desktop-winit/src/debugger.rs @@ -28,6 +28,8 @@ pub struct HostDebugger { last_telemetry_frame: u64, /// Last fault summary sent to the debugger client. last_fault_summary: Option, + /// Debug cartridge metadata captured before the VM session exists. + debug_cartridge: Option, } impl Default for HostDebugger { @@ -46,19 +48,25 @@ impl HostDebugger { last_log_seq: 0, last_telemetry_frame: 0, last_fault_summary: None, + debug_cartridge: None, } } /// Configures the debugger based on the boot target. /// If debug mode is enabled, it binds to the specified TCP port. - pub fn setup_boot_target(&mut self, boot_target: &BootTarget, firmware: &mut Firmware) { + pub fn setup_boot_target(&mut self, boot_target: &BootTarget, _firmware: &mut Firmware) { if let BootTarget::Cartridge { path, debug: true, debug_port } = boot_target { self.waiting_for_start = true; // Pre-load cartridge metadata so the Handshake message can contain - // valid information about the App being debugged. + // valid information before the VM session is created. if let Ok(cartridge) = CartridgeLoader::load(path) { - let _ = firmware.os.vm().initialize(&mut firmware.vm, &cartridge); + self.debug_cartridge = Some(HandshakeCartridge { + app_id: cartridge.app_id, + title: cartridge.title, + app_version: cartridge.app_version, + app_mode: cartridge.app_mode, + }); } match TcpListener::bind(format!("127.0.0.1:{}", debug_port)) { @@ -116,12 +124,7 @@ impl HostDebugger { let handshake = DebugResponse::Handshake { protocol_version: DEVTOOLS_PROTOCOL_VERSION, runtime_version: "0.1".to_string(), - cartridge: HandshakeCartridge { - app_id: firmware.os.vm().current_app_id(), - title: firmware.os.vm().current_cartridge_title(), - app_version: firmware.os.vm().current_cartridge_app_version(), - app_mode: firmware.os.vm().current_cartridge_app_mode(), - }, + cartridge: self.handshake_cartridge(firmware), }; self.send_response(handshake); } else { @@ -201,7 +204,7 @@ impl HostDebugger { DebugCommand::Step => { // Execute exactly one instruction and keep paused. firmware.os.vm().set_paused(true); - let _ = firmware.os.vm().debug_step_instruction(&mut firmware.vm, platform); + let _ = firmware.os.vm().debug_step_active_session(platform); } DebugCommand::StepFrame => { // Execute until the end of the current logical frame. @@ -210,10 +213,10 @@ impl HostDebugger { } DebugCommand::GetState => { // Return detailed VM register and stack state. - let stack_top = firmware.vm.operand_stack_top(10); + let stack_top = firmware.os.vm().active_operand_stack_top(10); let resp = DebugResponse::GetState { - pc: firmware.vm.pc(), + pc: firmware.os.vm().active_pc().unwrap_or(0), stack_top, frame_index: firmware.os.vm().logical_frame_index(), app_id: firmware.os.vm().current_app_id(), @@ -221,18 +224,37 @@ impl HostDebugger { self.send_response(resp); } DebugCommand::SetBreakpoint { pc } => { - firmware.vm.insert_breakpoint(pc); + let _ = firmware.os.vm().insert_active_breakpoint(pc); } DebugCommand::ListBreakpoints => { - let pcs = firmware.vm.breakpoints_list(); + let pcs = firmware.os.vm().active_breakpoints_list(); self.send_response(DebugResponse::Breakpoints { pcs }); } DebugCommand::ClearBreakpoint { pc } => { - firmware.vm.remove_breakpoint(pc); + let _ = firmware.os.vm().remove_active_breakpoint(pc); } } } + fn handshake_cartridge(&self, firmware: &mut Firmware) -> HandshakeCartridge { + let app_id = firmware.os.vm().current_app_id(); + if app_id != 0 { + return HandshakeCartridge { + app_id, + title: firmware.os.vm().current_cartridge_title(), + app_version: firmware.os.vm().current_cartridge_app_version(), + app_mode: firmware.os.vm().current_cartridge_app_mode(), + }; + } + + self.debug_cartridge.clone().unwrap_or_else(|| HandshakeCartridge { + app_id, + title: firmware.os.vm().current_cartridge_title(), + app_version: firmware.os.vm().current_cartridge_app_version(), + app_mode: firmware.os.vm().current_cartridge_app_mode(), + }) + } + pub(crate) fn cert_event_from_snapshot( tag: u16, telemetry: TelemetryFrame, @@ -315,7 +337,7 @@ impl HostDebugger { // Map specific internal log tags to protocol events. if event.tag == 0xDEB1 { self.send_event(DebugEvent::BreakpointHit { - pc: firmware.vm.pc(), + pc: firmware.os.vm().active_pc().unwrap_or(0), frame_index: firmware.os.vm().logical_frame_index(), }); } @@ -409,6 +431,52 @@ mod tests { assert!(firmware.os.vm().debug_step_requested()); } + #[test] + fn breakpoint_commands_mutate_active_vm_session() { + let mut debugger = HostDebugger::new(); + let mut firmware = Firmware::new(None); + let mut platform = TestPlatform::new(); + let task_id = firmware + .os + .sessions() + .try_create_vm_game_task(42, "Sector Crawl") + .expect("debugger test should create a game session"); + + debugger.handle_command( + DebugCommand::SetBreakpoint { pc: 123 }, + &mut firmware, + &mut platform, + ); + + assert_eq!( + firmware + .os + .sessions() + .vm_session_for_task(task_id) + .expect("game VM session should exist") + .vm + .breakpoints_list(), + vec![123] + ); + + debugger.handle_command( + DebugCommand::ClearBreakpoint { pc: 123 }, + &mut firmware, + &mut platform, + ); + + assert!( + firmware + .os + .sessions() + .vm_session_for_task(task_id) + .expect("game VM session should exist") + .vm + .breakpoints_list() + .is_empty() + ); + } + #[test] fn handle_command_start_leaves_waiting_for_start_mode() { let mut debugger = HostDebugger::new(); diff --git a/crates/host/prometeu-host-desktop-winit/src/input.rs b/crates/host/prometeu-host-desktop-winit/src/input.rs index 7d9874ef..68ab234c 100644 --- a/crates/host/prometeu-host-desktop-winit/src/input.rs +++ b/crates/host/prometeu-host-desktop-winit/src/input.rs @@ -43,9 +43,27 @@ impl FbViewport { pub struct HostInputHandler { pub signals: InputSignals, + system_controls: HostSystemControls, display_size: (usize, usize), } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct HostSystemControls { + home_requested: bool, +} + +impl HostSystemControls { + pub fn request_home(&mut self) { + self.home_requested = true; + } + + pub fn take_home_requested(&mut self) -> bool { + let requested = self.home_requested; + self.home_requested = false; + requested + } +} + impl Default for HostInputHandler { fn default() -> Self { Self::new((480, 270)) @@ -54,35 +72,51 @@ impl Default for HostInputHandler { impl HostInputHandler { pub fn new(display_size: (usize, usize)) -> Self { - Self { signals: InputSignals::default(), display_size } + Self { + signals: InputSignals::default(), + system_controls: HostSystemControls::default(), + display_size, + } + } + + pub fn take_home_requested(&mut self) -> bool { + self.system_controls.take_home_requested() + } + + pub fn clear_guest_signals(&mut self) { + self.signals = InputSignals::default(); + } + + pub fn handle_key_code(&mut self, code: KeyCode, state: ElementState) { + let is_down = state == ElementState::Pressed; + + match code { + KeyCode::Escape | KeyCode::Home if is_down => self.system_controls.request_home(), + + KeyCode::ArrowUp => self.signals.up_signal = is_down, + KeyCode::ArrowDown => self.signals.down_signal = is_down, + KeyCode::ArrowLeft => self.signals.left_signal = is_down, + KeyCode::ArrowRight => self.signals.right_signal = is_down, + + KeyCode::KeyA => self.signals.a_signal = is_down, + KeyCode::KeyD => self.signals.b_signal = is_down, + KeyCode::KeyW => self.signals.x_signal = is_down, + KeyCode::KeyS => self.signals.y_signal = is_down, + KeyCode::KeyQ => self.signals.l_signal = is_down, + KeyCode::KeyE => self.signals.r_signal = is_down, + + KeyCode::KeyZ => self.signals.start_signal = is_down, + KeyCode::ShiftLeft | KeyCode::ShiftRight => self.signals.select_signal = is_down, + + _ => {} + } } pub fn handle_event(&mut self, event: &WindowEvent, window: &Window) { match event { WindowEvent::KeyboardInput { event, .. } => { if let PhysicalKey::Code(code) = event.physical_key { - let is_down = event.state == ElementState::Pressed; - - match code { - KeyCode::ArrowUp => self.signals.up_signal = is_down, - KeyCode::ArrowDown => self.signals.down_signal = is_down, - KeyCode::ArrowLeft => self.signals.left_signal = is_down, - KeyCode::ArrowRight => self.signals.right_signal = is_down, - - KeyCode::KeyA => self.signals.a_signal = is_down, - KeyCode::KeyD => self.signals.b_signal = is_down, - KeyCode::KeyW => self.signals.x_signal = is_down, - KeyCode::KeyS => self.signals.y_signal = is_down, - KeyCode::KeyQ => self.signals.l_signal = is_down, - KeyCode::KeyE => self.signals.r_signal = is_down, - - KeyCode::KeyZ => self.signals.start_signal = is_down, - KeyCode::ShiftLeft | KeyCode::ShiftRight => { - self.signals.select_signal = is_down - } - - _ => {} - } + self.handle_key_code(code, event.state); } } @@ -134,3 +168,81 @@ impl HostInputHandler { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_no_pad_signal(signals: InputSignals) { + assert!(!signals.up_signal); + assert!(!signals.down_signal); + assert!(!signals.left_signal); + assert!(!signals.right_signal); + assert!(!signals.a_signal); + assert!(!signals.b_signal); + assert!(!signals.x_signal); + assert!(!signals.y_signal); + assert!(!signals.l_signal); + assert!(!signals.r_signal); + assert!(!signals.start_signal); + assert!(!signals.select_signal); + assert!(!signals.f_signal); + } + + #[test] + fn escape_requests_home_without_mutating_guest_pad() { + let mut input = HostInputHandler::default(); + + input.handle_key_code(KeyCode::Escape, ElementState::Pressed); + + assert!(input.take_home_requested()); + assert!(!input.take_home_requested()); + assert_no_pad_signal(input.signals); + } + + #[test] + fn physical_home_requests_home_without_mutating_guest_pad() { + let mut input = HostInputHandler::default(); + + input.handle_key_code(KeyCode::Home, ElementState::Pressed); + + assert!(input.take_home_requested()); + assert_no_pad_signal(input.signals); + } + + #[test] + fn home_release_does_not_emit_a_new_home_request() { + let mut input = HostInputHandler::default(); + + input.handle_key_code(KeyCode::Home, ElementState::Released); + + assert!(!input.take_home_requested()); + assert_no_pad_signal(input.signals); + } + + #[test] + fn existing_keyboard_pad_mapping_is_preserved() { + let mut input = HostInputHandler::default(); + + input.handle_key_code(KeyCode::KeyZ, ElementState::Pressed); + input.handle_key_code(KeyCode::ShiftLeft, ElementState::Pressed); + input.handle_key_code(KeyCode::ArrowUp, ElementState::Pressed); + + assert!(input.signals.start_signal); + assert!(input.signals.select_signal); + assert!(input.signals.up_signal); + assert!(!input.take_home_requested()); + } + + #[test] + fn clearing_guest_signals_preserves_system_home_control() { + let mut input = HostInputHandler::default(); + + input.handle_key_code(KeyCode::ArrowUp, ElementState::Pressed); + input.handle_key_code(KeyCode::Escape, ElementState::Pressed); + input.clear_guest_signals(); + + assert_no_pad_signal(input.signals); + assert!(input.take_home_requested()); + } +} diff --git a/crates/host/prometeu-host-desktop-winit/src/runner.rs b/crates/host/prometeu-host-desktop-winit/src/runner.rs index c6a9cc8c..0bb7611e 100644 --- a/crates/host/prometeu-host-desktop-winit/src/runner.rs +++ b/crates/host/prometeu-host-desktop-winit/src/runner.rs @@ -10,8 +10,8 @@ use pixels::{Pixels, PixelsBuilder, SurfaceTexture}; use prometeu_drivers::hardware::Hardware; use prometeu_drivers::{AudioCommand, LocalFramebufferRenderBackend, MemoryBanks}; use prometeu_firmware::{BootTarget, Firmware, FirmwareState}; -use prometeu_hal::RuntimePlatform; use prometeu_hal::telemetry::CertificationConfig; +use prometeu_hal::{OwnedRgba8888Frame, RenderOwnership, RuntimePlatform}; use prometeu_system::{LatestRenderFrameStore, RenderWorkerConfig, discover_games_root}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -88,8 +88,12 @@ fn desired_control_flow( } } -fn should_present_worker_frame(state: &FirmwareState) -> bool { - matches!(state, FirmwareState::GameRunning(_)) +fn should_present_worker_frame( + state: &FirmwareState, + frame: &OwnedRgba8888Frame, + active_ownership: RenderOwnership, +) -> bool { + matches!(state, FirmwareState::GameRunning(_)) && frame.ownership == active_ownership } /// The Desktop implementation of the PROMETEU Runtime. @@ -312,8 +316,13 @@ impl ApplicationHandler for HostRunner { { let frame = pixels.frame_mut(); - if should_present_worker_frame(&self.firmware.state) - && let Some(worker_frame) = self.render_frame_store.latest_frame() + let active_ownership = self.firmware.os.vm().active_render_ownership(); + if let Some(worker_frame) = self.render_frame_store.latest_frame() + && should_present_worker_frame( + &self.firmware.state, + &worker_frame, + active_ownership, + ) { self.firmware .os @@ -372,6 +381,13 @@ impl ApplicationHandler for HostRunner { self.firmware.os.fs().mount(Box::new(backend)); } + if self.input.take_home_requested() { + self.input.clear_guest_signals(); + self.firmware.request_home_from_host(); + self.invalidate_host_surface(); + self.request_redraw_if_needed(); + } + // 3. Timing Management (The heart of determinism). // We measure the elapsed time since the last iteration and add it to an // accumulator. We then execute exactly as many 60Hz slices as the @@ -399,7 +415,10 @@ impl ApplicationHandler for HostRunner { // Sync pause state with audio. // We do this AFTER firmware.tick to avoid MasterPause/Resume commands // being cleared by the OS if a new logical frame starts in this tick. - let is_paused = self.firmware.os.vm().paused() || self.debugger.waiting_for_start; + let lifecycle_audio_paused = self.firmware.game_lifecycle_audio_paused(); + let is_paused = lifecycle_audio_paused + || self.firmware.os.vm().paused() + || self.debugger.waiting_for_start; if is_paused != self.last_paused_state { self.last_paused_state = is_paused; let cmd = @@ -414,8 +433,9 @@ impl ApplicationHandler for HostRunner { self.stats.record_frame(); } - if should_present_worker_frame(&self.firmware.state) - && let Some(worker_frame) = self.render_frame_store.latest_frame() + let active_ownership = self.firmware.os.vm().active_render_ownership(); + if let Some(worker_frame) = self.render_frame_store.latest_frame() + && should_present_worker_frame(&self.firmware.state, &worker_frame, active_ownership) { self.presentation.note_published_frame(worker_frame.frame_id.get()); } else { @@ -536,14 +556,39 @@ mod tests { } #[test] - fn worker_frame_presentation_is_game_only() { - assert!(should_present_worker_frame(&FirmwareState::GameRunning(GameRunningStep::new( - TaskId(1), - )))); - assert!(!should_present_worker_frame(&FirmwareState::ShellRunning(ShellRunningStep::new( - TaskId(2) - ),))); - assert!(!should_present_worker_frame(&FirmwareState::HubHome(HubHomeStep))); + fn worker_frame_presentation_requires_game_state_and_current_ownership() { + let active = RenderOwnership::new(3, AppMode::Game, 1); + let current = OwnedRgba8888Frame::packed(FrameId::new(9), active, 1, 1, vec![0xFF00FFFF]) + .expect("test frame"); + let stale = OwnedRgba8888Frame::packed( + FrameId::new(8), + RenderOwnership::new(2, AppMode::Game, 1), + 1, + 1, + vec![0xFF00FFFF], + ) + .expect("test frame"); + + assert!(should_present_worker_frame( + &FirmwareState::GameRunning(GameRunningStep::new(TaskId(1))), + ¤t, + active, + )); + assert!(!should_present_worker_frame( + &FirmwareState::GameRunning(GameRunningStep::new(TaskId(1),)), + &stale, + active, + )); + assert!(!should_present_worker_frame( + &FirmwareState::ShellRunning(ShellRunningStep::new(TaskId(2))), + ¤t, + active, + )); + assert!(!should_present_worker_frame( + &FirmwareState::HubHome(HubHomeStep), + ¤t, + active, + )); } #[test] diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 48b236ba..519b2791 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,8 +1,8 @@ -{"type":"meta","next_id":{"DSC":44,"AGD":46,"DEC":37,"PLN":136,"LSN":51,"CLSN":1}} +{"type":"meta","next_id":{"DSC":44,"AGD":47,"DEC":39,"PLN":157,"LSN":53,"CLSN":1}} {"type":"discussion","id":"DSC-0043","status":"open","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-03","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."} {"type":"discussion","id":"DSC-0040","status":"done","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-06","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0048","file":"discussion/lessons/DSC-0040-vm-render-parallel-execution-boundary/LSN-0048-render-workers-need-a-closed-packet-contract.md","status":"done","created_at":"2026-06-06","updated_at":"2026-06-06"}]} -{"type":"discussion","id":"DSC-0041","status":"open","ticket":"foreground-stack-game-pause-shell-vm-backed","title":"Foreground Stack, Game Pause, and VM-Backed Shell Coexistence","created_at":"2026-06-05","updated_at":"2026-06-05","tags":["runtime","os","lifecycle","shell","game","vm","foreground","architecture"],"agendas":[{"id":"AGD-0041","file":"AGD-0041-foreground-stack-game-pause-shell-vm-backed.md","status":"open","created_at":"2026-06-05","updated_at":"2026-06-05"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0041","status":"done","ticket":"foreground-stack-game-pause-shell-vm-backed","title":"Foreground Stack, Game Pause, and VM-Backed Shell Coexistence","created_at":"2026-06-05","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","shell","game","vm","foreground","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0052","file":"discussion/lessons/DSC-0041-foreground-stack-game-pause-shell-vm-backed/LSN-0052-foreground-ownership-and-vm-session-ownership-are-separate.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]} {"type":"discussion","id":"DSC-0042","status":"done","ticket":"real-render-worker-establishment","title":"Real Render Worker Establishment","created_at":"2026-06-06","updated_at":"2026-06-20","tags":["runtime","renderer","worker","concurrency","host","hal","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0049","file":"discussion/lessons/DSC-0042-real-render-worker-establishment/LSN-0049-render-worker-publication-boundary.md","status":"done","created_at":"2026-06-20","updated_at":"2026-06-20"}]} {"type":"discussion","id":"DSC-0038","status":"done","ticket":"render-frame-packet-boundary","title":"Logical Render Pipelines and Command Packets","created_at":"2026-05-25","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","frame-composer","architecture","ui","pipeline"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0047","file":"discussion/lessons/DSC-0038-render-frame-packet-boundary/LSN-0047-typed-render-submissions-preserve-domain-boundaries.md","status":"done","created_at":"2026-06-04","updated_at":"2026-06-04"}]} {"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"}]} @@ -14,7 +14,7 @@ {"type":"discussion","id":"DSC-0001","status":"done","ticket":"legacy-runtime-learn-import","title":"Import legacy runtime learn into discussion lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["migration","tech-debt"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0001-prometeu-learn-index.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0002-historical-asset-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0003-historical-audio-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0004-historical-cartridge-boot-protocol-and-manifest-authority.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0005-historical-game-memcard-slots-surface-and-semantics.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0006-historical-gfx-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0007-historical-retired-fault-and-input-decisions.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0008-historical-vm-core-and-assets.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0009","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0009-mental-model-asset-management.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0010","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0010-mental-model-audio.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0011","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0011-mental-model-gfx.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0012","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0012-mental-model-input.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0013","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0013-mental-model-observability-and-debugging.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0014","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0014-mental-model-portability-and-cross-platform.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0015","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0015-mental-model-save-memory-and-memcard.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0016-mental-model-status-first-and-fault-thinking.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0017","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0017-mental-model-time-and-cycles.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0018","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]} {"type":"discussion","id":"DSC-0002","status":"done","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-04-21","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0037","file":"discussion/lessons/DSC-0002-runtime-edge-test-plan/LSN-0037-domain-gates-must-be-owned-by-the-repository.md","status":"done","created_at":"2026-04-21","updated_at":"2026-04-21"}]} {"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} -{"type":"discussion","id":"DSC-0004","status":"in_progress","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-07-03","tags":[],"agendas":[{"id":"AGD-0003","file":"AGD-0003-system-run-cart.md","status":"accepted","created_at":"2026-03-27","updated_at":"2026-07-03"},{"id":"AGD-0045","file":"AGD-0045-systemos-library-root-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[{"id":"DEC-0035","file":"DEC-0035-remove-userland-system-run-cart-and-preserve-direct-boot.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0003"},{"id":"DEC-0036","file":"DEC-0036-systemos-games-root-and-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0045"}],"plans":[{"id":"PLN-0129","file":"PLN-0129-remove-userland-run-cart-abi-surface.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0130","file":"PLN-0130-remove-runtime-run-cart-stub-dispatch.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0131","file":"PLN-0131-preserve-direct-cartridge-boot-coverage.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0132","file":"PLN-0132-specify-games-root-home-launch-contract.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0133","file":"PLN-0133-implement-games-root-library-discovery.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0134","file":"PLN-0134-wire-home-game-list-launch-flow.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0135","file":"PLN-0135-validate-games-root-launch-end-to-end.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0004","status":"done","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-07-03","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0051","file":"discussion/lessons/DSC-0004-system-run-cart/LSN-0051-launch-authority-belongs-to-systemos.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03"}]} {"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"AGD-0004-system-fault-semantics-and-control-surface.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0006","status":"open","ticket":"vm-owned-random-service","title":"Agenda - VM-Owned Random Service","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0005","file":"AGD-0005-vm-owned-random-service.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0007","status":"open","ticket":"app-home-filesystem-surface-and-semantics","title":"Agenda - App Home Filesystem Surface and Semantics","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0006","file":"AGD-0006-app-home-filesystem-surface-and-semantics.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} diff --git a/discussion/lessons/DSC-0004-system-run-cart/LSN-0051-launch-authority-belongs-to-systemos.md b/discussion/lessons/DSC-0004-system-run-cart/LSN-0051-launch-authority-belongs-to-systemos.md new file mode 100644 index 00000000..d88d2d7f --- /dev/null +++ b/discussion/lessons/DSC-0004-system-run-cart/LSN-0051-launch-authority-belongs-to-systemos.md @@ -0,0 +1,118 @@ +--- +id: LSN-0051 +ticket: system-run-cart +title: Launch Authority Belongs to SystemOS +created: 2026-07-03 +tags: [runtime, systemos, cartridge, launch, abi, home] +--- + +## Context + +The `system.run_cart` workflow resolved two related questions: whether a guest +should have an app-callable cartridge launch syscall, and how Home should start +games from a local library. + +The final model removes `system.run_cart` from the userland ABI while preserving +direct host-controlled cartridge boot. Home launch is restored as a +system-owned path: the host may provide `--games-root `, SystemOS/Home +discovers valid Game directory cartridges, and selecting an entry routes through +firmware into `LoadCartridge`. + +The important distinction is authority. A launch can be a valid product action +without being a guest capability. + +## Key Decisions + +### Remove Guest Cartridge Launch + +**What:** Prometeu removed `system.run_cart` from the public userland syscall +surface, generated guest metadata, and runtime dispatch. + +**Why:** A guest syscall made cartridge launch look like app-owned navigation. +That gave userland implied authority over target resolution, lifecycle cleanup, +failure policy, and system navigation. + +**Trade-offs:** Obsolete guest code cannot keep a compatibility stub. The +runtime instead keeps direct boot as a host/system entrypoint and lets stale +guest attempts fail through the invalid syscall path. + +### Preserve Direct Boot Outside The Guest ABI + +**What:** Host, CLI, debugger, tests, and single-game flows continue to boot a +selected cartridge directly. + +**Why:** Direct boot is still essential for development, automation, and future +single-game distribution. Those flows start with a host-selected target before +VM execution, so they do not need a guest syscall. + +**Trade-offs:** The loader remains reusable, but it is not lifecycle +orchestration authority. Higher-level transitions must be owned by firmware or +SystemOS. + +### Model Home Launch As A System Action + +**What:** `--games-root ` configures a local games library for Home. V1 +discovery scans immediate child directories, keeps only valid Game cartridges, +and exposes enough internal metadata to launch a selected entry. + +**Why:** Home needs a product path for "choose a game and start it", but that +action belongs to the system profile. SystemOS can present the library and ask +firmware to enter `LoadCartridge` without reintroducing guest-controlled launch. + +**Trade-offs:** V1 intentionally excludes recursive discovery, `.pmc`, non-game +apps, rich catalog metadata, return-to-Home, and game-to-game switching. Those +need separate lifecycle and orchestration decisions. + +## Patterns and Algorithms + +### Authority-Based API Classification + +Before exposing an operation to guest/userland code, classify what authority it +implies. If the operation selects another executable target, changes process +lifecycle, or controls navigation between system-owned modes, it is probably a +SystemOS or firmware action rather than an app syscall. + +### Separate Selection From Loading + +Home owns user selection and presentation. SystemOS owns the launch request. +Firmware owns the transition into `LoadCartridge`. The cartridge loader remains +the mechanism that validates and materializes a cartridge, not the authority +that decides when navigation is allowed. + +### Expandable Internal Catalog Records + +Home can render a small v1 list while the internal entry retains manifest data, +title, app id, app version, cartridge path, and discovery metadata. This keeps +the UI simple without reducing the model to a display-only DTO. + +## Pitfalls + +### Compatibility Stubs Preserve False Contracts + +A syscall that returns success without doing real lifecycle work is worse than +an absent syscall. It teaches callers that the platform supports app-driven +launch while hiding that no complete transition happened. + +### A Product Flow Is Not Automatically A Guest API + +"Home can launch a game" and "a guest can launch a cartridge" are different +claims. Product behavior should be mapped to the component with the correct +authority instead of mirrored into the nearest callable ABI. + +### Generic Library Roots Prematurely Expand Scope + +The first launcher path is a games library, not a marketplace or full app +catalog. Shell apps, System apps, packages, icons, search, and recursive layouts +carry separate contracts and should not be smuggled into the v1 games-root path. + +## Takeaways + +- Cartridge launch is system navigation, not a userland syscall. +- Direct boot can remain host-controlled without appearing in guest metadata. +- Home launch should emit a system action and let firmware enter + `LoadCartridge`. +- Loader reuse does not imply loader ownership of lifecycle policy. +- Keep v1 discovery narrow: immediate child directory cartridges with + `app_mode: Game`. +- Preserve internal catalog metadata even when the Home UI renders only a small + subset. diff --git a/discussion/lessons/DSC-0041-foreground-stack-game-pause-shell-vm-backed/LSN-0052-foreground-ownership-and-vm-session-ownership-are-separate.md b/discussion/lessons/DSC-0041-foreground-stack-game-pause-shell-vm-backed/LSN-0052-foreground-ownership-and-vm-session-ownership-are-separate.md new file mode 100644 index 00000000..c33ccd00 --- /dev/null +++ b/discussion/lessons/DSC-0041-foreground-stack-game-pause-shell-vm-backed/LSN-0052-foreground-ownership-and-vm-session-ownership-are-separate.md @@ -0,0 +1,145 @@ +--- +id: LSN-0052 +ticket: foreground-stack-game-pause-shell-vm-backed +title: Foreground Ownership and VM Session Ownership Are Separate +created: 2026-07-05 +tags: [runtime, os, lifecycle, shell, game, vm, foreground, architecture] +--- + +# LSN-0052: Foreground Ownership and VM Session Ownership Are Separate + +## Context + +DSC-0041 closed the `Game -> Home/Shell -> same Game` lifecycle for the first +runtime implementation that supports both native Shell apps and VM-backed Shell +apps. The important architectural shift was not only pausing the Game. The +runtime also had to stop treating one firmware-owned VM as the place where all +guest execution state lives. + +The final model separates two questions: + +- Who owns the foreground visual/input authority right now? +- Which VM-backed process owns a mutable VM execution context? + +Foreground ownership is global SystemOS policy. VM execution state belongs to a +session associated with a task/process. A suspended resident Game is not a saved +snapshot and not a special global VM. It is a live VM session that is temporarily +ineligible for normal foreground ticks, input, frame pacing, and render +publication. + +## Key Decisions + +### Foreground Stack and Game Pause Contract + +**What:** PROMETEU uses a single foreground owner in v1. Hub/Home is the root +Shell owner, a Game may remain resident, and one foreground Shell app may run in +front of the resident Game. Pressing desktop `Esc` is a host/SystemOS Home +request, not guest input. + +**Why:** This preserves console-like navigation without turning the runtime into +a general multitasking scheduler. It lets the system pause and suspend a Game, +run Hub or Shell, then resume the same Game deterministically. + +**Trade-offs:** The model intentionally rejects multiple resident Games, direct +Shell-to-Game return, background execution, and Game-to-Game switching in v1. +Those features remain future work, but the lifecycle structure now has a place +to add them. + +### VM Session Ownership for VM-Backed Processes + +**What:** VM-backed mutable execution state is owned by VM sessions. A +VM-backed Game and a VM-backed Shell never share the same VM, stack, heap, PC, +open handles, cartridge identity, or VM-scoped runtime state. + +**Why:** A single firmware-owned VM cannot represent a suspended resident Game +and a foreground VM Shell at the same time. Loading the Shell into that global +VM would overwrite the Game context that the lifecycle model says is resident. + +**Trade-offs:** Session ownership is more explicit than one global VM, but it +keeps the model simpler than snapshot/restore. It also prepares the runtime for +future background-capable sessions without committing to background scheduling +yet. + +## Patterns and Algorithms + +### Pause Is Cooperative, Suspension Is OS Authority + +The Game receives lifecycle events such as pause and resume, but the OS owns the +actual scheduling state. If the Game does not cooperate within the bounded pause +budget, SystemOS can suspend it anyway. On resume, the Game receives a foreground +restore event and may still keep its internal pause screen while it +synchronizes. + +This distinction prevents userland from owning system navigation. The Game may +observe and react. It cannot veto Home. + +### Foreground Owner Is Not the Same as Tick Eligibility + +Foreground ownership controls input, visual authority, render ownership, and +normal frame pacing. Tick eligibility controls whether a VM session may execute. +In v1, only the foreground VM session receives normal ticks, and the resident +Game does not tick while Shell or Hub is foreground. + +The important design detail is that the data model does not assume this must be +true forever. Sessions can later become background-eligible without changing who +owns render/input foreground. + +### VM Sessions Are the Unit of Mutable Guest State + +Each VM-backed task/process maps to a `VmSession`. The session owns the VM, +runtime state, session-scoped filesystem state, open file handles, next handle +allocation, cartridge identity, lifecycle delivery state, and debug state. + +Cartridge loading creates or reuses a VM session through SystemOS session +services. Firmware orchestrates macro states such as `LoadCartridge`, +`GameRunning`, `HubHome`, and `ShellRunning`, but it no longer owns a canonical +guest VM. + +### Debugger and Host Inspection Must Follow the Active Session + +Host debugging must inspect and mutate the active session VM, not a transitional +global VM. PC, operand stack, breakpoints, debug-step execution, cartridge +identity, and breakpoint-hit events all need to resolve through active session +state. + +This keeps debugger behavior aligned with actual execution. A debugger that +observes a different VM than the scheduler ticks is worse than no debugger: it +creates false confidence and hides lifecycle bugs. + +### Operational Failures Should Cross Facades as Typed Errors + +Session creation and loading are operational boundaries. Missing task, missing +process, duplicate VM session, missing VM session during initialization, and +foreground Shell rejection should be typed errors. Firmware can then choose a +controlled crash report, a launch rejection, or a logged no-transition outcome. + +Panics are reserved for test/setup wrappers or impossible internal invariants +that have already been proven by prior checks. + +## Pitfalls + +- Do not use a global VM as a compatibility bridge after session ownership is + introduced. It will eventually become stale or contradictory. +- Do not let Home/SystemOS be guest input. It changes OS authority, so it must + bypass guest pad state. +- Do not present old frames after foreground ownership changes. Render ownership + and epoch validation are part of lifecycle correctness, not renderer polish. +- Do not model pause and suspension as the same state. Pause is visible to the + Game; suspension is the scheduler mechanism. +- Do not encode v1 limits such as one resident Game or one foreground Shell by + collapsing storage into one VM. Express those limits as lifecycle/session + policy. +- Do not let debugger state drift from scheduler state. The debugger must use + the same active session as normal execution. + +## Takeaways + +- Foreground ownership is global policy; VM execution state is session-owned. +- A suspended Game is a preserved session, not a serialized VM and not a global + VM waiting to be reused. +- Shell apps can be native or VM-backed, but only VM-backed processes need VM + sessions. +- Session ownership is the foundation for future background-capable processes, + even before background execution exists. +- Typed session/lifecycle errors make launch and recovery behavior explicit at + firmware boundaries. diff --git a/discussion/workflow/agendas/AGD-0003-system-run-cart.md b/discussion/workflow/agendas/AGD-0003-system-run-cart.md deleted file mode 100644 index b0e1b2ad..00000000 --- a/discussion/workflow/agendas/AGD-0003-system-run-cart.md +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: AGD-0003 -ticket: system-run-cart -title: Agenda - System Run Cart -status: accepted -created: 2026-03-27 -resolved: -decision: -tags: [] ---- - -# Agenda - System Run Cart - -## Problema - -`system.run_cart` existe na ABI pública, mas hoje não produz efeito real no runtime. -Isso expõe duas questões: a superfície atual é um placebo e, mesmo quando houver -fluxo real, rodar cartuchos não deve ser uma operação userland. A autoridade -para iniciar cartuchos deve pertencer ao sistema/firmware. - -Isso cria uma falsa promessa de plataforma: - -- o programa consegue declarar e chamar uma operação que aparenta iniciar cartuchos; -- o loader aceita sua existência; -- o runtime retorna sucesso vazio; -- nenhuma troca de cartucho, transição de firmware, validação de alvo ou política de segurança ocorre. - -## Dor - -- A ABI expõe uma capability de sistema que, na prática, nao existe. -- Mesmo se implementada, a operação não deve ser uma syscall userland comum: - apps não devem iniciar cartuchos diretamente. -- O guest pode acreditar que solicitou uma troca de app quando nada aconteceu. -- Isso corrói a confiabilidade da plataforma: a interface pública deixa de ser contrato e vira placebo. -- Qualquer ferramenta, SDK ou documentação construída sobre essa syscall passa a modelar um comportamento inexistente. - -## Alvo da Discussao - -Definir o contrato real da troca de cartucho iniciada pelo sistema do ponto de vista: - -- do guest; -- do runtime; -- do firmware; -- do host/cartridge loader. - -Ao final da discussão, deve ficar claro se a superfície atual: - -- permanece apenas como mecanismo interno do sistema e sai da ABI userland; ou -- permanece na ABI somente como superfície privilegiada impossível de chamar por apps comuns; ou -- sai temporariamente da ABI até existir fluxo fechado. - -Esta discussao nao deve depender do fechamento previo do formato `.pmc`. -Para o primeiro fluxo fechado, `system.run_cart` pode resolver um alvo por -um loader ja disponivel, incluindo cartucho em diretorio ou fixture de teste. -O suporte ao artefato empacotado `.pmc` permanece responsabilidade da -`DSC-0003 / packed-cartridge-loader-pmc` e nao deve bloquear a prova inicial -de troca `Shell/Hub -> Game`. - -Tambem precisa ficar claro que `run_cart`, se existir, nao e o orquestrador da -troca. No boot direto por linha de comando, carregar um cartucho e simples -porque o runtime nasce ja apontado para aquele jogo. Com o SO em execucao, o -caso real e diferente: pode haver um Game residente/rodando, o usuario volta -para Home, e o sistema decide iniciar outro Game. Nesse caso o SO precisa -orquestrar fechamento/suspensao descartada do app atual, limpeza de estado, -resolucao do novo alvo, carregamento e transicao para o proximo estado de -firmware. O carregador de cartucho e apenas uma etapa desse fluxo. - -A direcao preferida e que a orquestracao de troca de cartucho viva no -SystemOS. Porem, essa agenda nao deve capturar o caso `Game -> Home -> mesmo -Game`: esse e o escopo da `AGD-0041`, que precisa fechar foreground, -pausa/suspensao e retorno ao mesmo Game. A troca `Home -> outro Game`, com -fechamento do jogo atual e carregamento de outro cartucho, pode merecer uma -agenda propria depois da `AGD-0041`, derivada do contrato de foreground que ela -fechar. - -## O Que Precisa Ser Definido - -1. Semântica da operação. - O que significa "run cart" quando existe SO: trocar imediatamente, - agendar para o próximo frame, iniciar uma transicao orquestrada pelo - sistema, ou apenas carregar bytes de cartucho como etapa interna? - -2. Origem do alvo. - Como o guest identifica o cartucho alvo: id numérico, nome canônico, path virtual, handle, manifesto, ou nenhum argumento nesta primeira versão? - Para v1, uma opcao aceitavel e limitar a resolucao a alvos conhecidos pelo host/test harness e carregaveis pelo loader de diretorio, deixando `.pmc` fora do caminho critico. - -3. Modelo de segurança. - Nenhum app/userland deve chamar `run_cart`. O sistema/firmware e o caminho - host/CLI/debug devem ser as unicas autoridades para iniciar cartuchos. - Se apps precisarem solicitar navegacao no futuro, isso deve ser uma - superficie nova e mediada pelo sistema. - -4. Ponto de integração. - Onde a transição vive: `VirtualMachineRuntime`, firmware, host, SystemOS, - ou combinação dos três? Para esta agenda, a decisao minima e separar o boot - direto host/CLI/debug da ABI guest. A orquestracao `Home -> outro Game` - pertence a `DSC-0043 / AGD-0044`. - -5. Efeitos observáveis. - O que acontece quando um guest tenta chamar a superficie removida, e como - ferramentas/boot direto continuam carregando cartuchos sem depender de uma - syscall userland? - -6. Contrato de erro. - Remocao da ABI significa erro de build/declaracao, syscall desconhecida, - ou bloqueio em registro/capability ate a limpeza completa das declaracoes? - -## O Que Necessita Para Resolver - -- remocao de `system.run_cart` da ABI userland; -- definicao de que boot direto continua por caminho host/CLI/debug, nao por - syscall guest; -- separacao explicita entre boot direto, loader interno e orquestracao - `Home -> outro Game` da `DSC-0043 / AGD-0044`; -- testes garantindo que guest/userland nao pode declarar/chamar `run_cart`; -- testes garantindo que boot direto de cartucho continua funcional. - -## Fora de Escopo - -- catálogo completo de apps instalados; -- UX final do launcher/hub; -- resolução remota ou download de cartuchos; -- política de marketplace/distribuição. -- obrigatoriedade de boot a partir de `.pmc` no primeiro fluxo funcional. - -## Sugestao / Recomendacao - -Recomendo remover `system.run_cart` da ABI userland para v1. Nao ha necessidade -de compatibilidade com a superficie atual: ela nao deve permanecer como stub, -syscall reservada chamavel por guest, nem sucesso vazio. - -Rodar cartuchos deve ser uma operacao interna do SystemOS/firmware ou do -caminho host/CLI/debug, nunca uma syscall de app. O boot direto deve continuar -existindo porque e o fluxo essencial para testar cartuchos em desenvolvimento e -tambem representa o modo futuro de executar um jogo unico, como em uma -integracao tipo Steam. - -Recomendo tambem separar dois conceitos: - -- **boot direto:** usado por linha de comando/debug, inicia o runtime ja com um - cartucho alvo e pode continuar simples; -- **troca via SO:** fluxo orquestrado pelo SystemOS, que fecha o app - atual quando necessario, limpa estado cartridge-scoped, resolve o proximo - alvo e so entao chama o carregador. - -Essa agenda nao deve implementar o fluxo `Game -> Home -> mesmo Game`; esse -trabalho pertence a `AGD-0041`. Depois dela, a troca `Home -> outro Game` deve -ser aberta ou refinada como discussao propria, porque adiciona fechamento do -jogo atual, limpeza cartridge-scoped, resolucao de novo alvo e falhas de load. - -Se a plataforma precisar de uma ação solicitável por apps no futuro, ela deve -ser modelada separadamente como pedido de navegação mediado pelo sistema, com -nome e erros que não prometam boot direto. - -## Respostas Atuais - -- `system.run_cart` deve ser removido da ABI userland. -- Nao precisamos manter compatibilidade com a superficie atual. -- Nao deve existir syscall publica para app iniciar cartucho. -- Boot direto por linha de comando, host, debug ou fluxo equivalente de jogo - unico deve permanecer. -- O carregamento interno pode continuar usando loader ja disponivel, incluindo - cartucho em diretorio/fixture. -- `.pmc`, catalogo, UX final de launcher e troca `Game A -> Home -> Game B` - ficam fora desta agenda. -- Se no futuro apps precisarem pedir navegacao, isso deve ser uma nova - superficie mediada pelo sistema, nao a volta de `run_cart` userland. - -## Critério de Saida Desta Agenda - -Esta agenda só pode virar PR de implementação quando existir decisão escrita para: - -- retirada da superfície userland atual; -- preservacao explicita do boot direto host/CLI/debug; -- comportamento esperado para declaracao/chamada guest obsoleta; -- fronteira com `AGD-0041` e `AGD-0044`; -- cobertura minima de testes para remocao userland e boot direto. - -## Discussao - -- 2026-07-03: A agenda deve ser aberta antes da `AGD-0041` como habilitadora - de validacao do ciclo `Shell/Hub -> Game`. Ficou definido como premissa de - agenda que o primeiro `system.run_cart` testavel nao depende de `.pmc`; ele - pode usar cartucho em diretorio ou fixture resolvida pelo host/test harness. -- 2026-07-03: Direcao ajustada: `run_cart` nao deve ser uma operacao userland. - A autoridade de iniciar cartuchos deve pertencer ao sistema/firmware; apps - podem, no maximo, solicitar navegacao por uma superficie separada e mediada. -- 2026-07-03: Direcao refinada: `run_cart` simples nao resolve o caso com SO. - Boot direto por linha de comando continua sendo um caminho simples, mas - iniciar outro jogo a partir do Home exige um orquestrador do SO/firmware que - fecha o app atual, limpa estado e so entao carrega o novo cartucho. -- 2026-07-03: Escopo refinado: `Game -> Home -> mesmo Game` pertence a - `AGD-0041`. A agenda atual nao deve absorver esse trabalho. Depois da - `AGD-0041`, a troca `Home -> outro Game` pode ser aberta ou refinada como - agenda propria para o orquestrador de troca de cartucho no SystemOS. -- 2026-07-03: A troca `Game A -> Home -> Game B` foi separada para - `DSC-0043 / AGD-0044`, mantendo esta agenda focada na superficie - `run_cart`/loader e sua remocao ou restricao como operacao userland. -- 2026-07-03: Direcao aceita para esta agenda: remover `system.run_cart` da ABI - userland sem necessidade de compatibilidade, manter boot direto para - CLI/debug/teste de cartuchos e futuro modo de jogo unico, e deixar navegacao - mediada por apps para uma superficie futura separada se ela vier a existir. diff --git a/discussion/workflow/agendas/AGD-0041-foreground-stack-game-pause-shell-vm-backed.md b/discussion/workflow/agendas/AGD-0041-foreground-stack-game-pause-shell-vm-backed.md deleted file mode 100644 index 973f74e2..00000000 --- a/discussion/workflow/agendas/AGD-0041-foreground-stack-game-pause-shell-vm-backed.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -id: AGD-0041 -ticket: foreground-stack-game-pause-shell-vm-backed -title: Foreground Stack, Game Pause, and VM-Backed Shell Coexistence -status: open -created: 2026-06-05 -resolved: -decision: -tags: [runtime, os, lifecycle, shell, game, vm, foreground, architecture] ---- - -## Contexto - -Durante a discussao da `DSC-0040` sobre separar VM e render worker, surgiu um caso que nao pertence somente ao render: `Game -> Shell -> Game`. - -O caso minimo e: - -- um Game esta rodando; -- o usuario aperta Home; -- o Game deve sair do foreground sem ser encerrado; -- o Hub/Shell assume a tela; -- o usuario pode abrir um app Shell, inclusive VM-backed; -- depois o usuario retorna ao mesmo Game. - -O render worker precisa saber qual visual owner pode apresentar frames, mas a decisao principal e de lifecycle: quais VMs existem, quais podem executar, o que significa pausar um Game, e como Shell apps coexistem com um Game pausado. - -Material relacionado: - -- `DSC-0031`: separacao entre perfis Game e System/Shell. -- `DSC-0032`: SystemOS como autoridade de lifecycle/process/task. -- `DSC-0035`: janelas Shell pertencem a tasks e definem liveness do app. -- `DSC-0036`: Hub/Shell prova fatias de lifecycle. -- `DSC-0040`: render ownership/epoch durante transicoes e paralelismo VM/render. - -## Problema - -Precisamos definir o contrato de foreground para impedir ambiguidades como: - -- Game pausado continuando a executar frames logicos enquanto Shell esta no foreground; -- Shell VM-backed competindo com Game por pacing, input, superficie ou render ownership; -- multiplos Shells foreground ou multiplos Games ativos sem uma politica explicita; -- retorno ao Game sem clareza sobre estado pausado, input, render invalidation e retomada da VM. - -Sem esse contrato, a separacao VM/render da `DSC-0040` pode ficar tecnicamente correta, mas o SystemOS ainda pode permitir estados incoerentes. - -## Pontos Criticos - -- Existe apenas um foreground visual owner por vez. -- Background execution e outro assunto e deve ficar fora da primeira decisao, salvo se for necessario definir uma proibicao explicita. -- Shell VM-backed nao deve herdar politica de game loop so por usar VM. -- Game pausado provavelmente deve reter estado, mas nao deve consumir frames logicos. -- O Hub/Shell precisa ser capaz de rodar enquanto o Game esta suspenso. -- A transicao deve invalidar render submissions antigas para impedir present de frames obsoletos. -- O modelo precisa caber no SystemOS/lifecycle existente, nao diretamente no render worker. - -## Opcoes - -### Opcao A - Foreground unico, Game suspenso, Shell unico - -- **Abordagem:** permitir no maximo um Game carregado e no maximo um Shell foreground; ao apertar Home, o Game entra em estado pausado/suspenso e Shell/Hub assume o foreground. Shell VM-backed pode executar apenas enquanto for o foreground Shell. -- **Pro:** modelo simples, previsivel e alinhado com console; reduz concorrencia real entre VMs; combina com a regra de render ownership unico. -- **Contra:** nao cobre multitarefa/background; futuras features de overlay ou apps persistentes precisarao expandir o contrato. -- **Manutenibilidade:** alta; SystemOS pode modelar uma pilha pequena de foreground/suspended sem virar scheduler generico. - -### Opcao B - Multiplas VMs residentes, somente foreground executa - -- **Abordagem:** permitir que Game e Shell VM-backed coexistam como VMs residentes, mas somente a entidade foreground executa. O Game pausado fica residente, sem tick; Shell VM-backed roda sob lifecycle Shell. -- **Pro:** preserva estado e permite retorno rapido; nao exige background execution. -- **Contra:** precisa contrato claro de memoria, ownership de input, foco, cancelamento e fechamento. -- **Manutenibilidade:** boa se os estados forem explicitos; ruim se a residencia for confundida com execucao em background. - -### Opcao C - Encerrar Game ao entrar no Shell VM-backed - -- **Abordagem:** Home fecha/salva o Game antes de abrir Shell VM-backed, evitando coexistencia. -- **Pro:** simplifica o runtime. -- **Contra:** quebra a expectativa de console moderno; torna `Game -> Shell -> Game` um reload, nao uma pausa. -- **Manutenibilidade:** simples no curto prazo, mas provavelmente gera mais excecoes e UX ruim depois. - -### Opcao D - Background real desde ja - -- **Abordagem:** permitir Game pausado ou Shell apps rodando em background com politicas de prioridade. -- **Pro:** cobre casos futuros ricos. -- **Contra:** amplia demais escopo: scheduler, budget, energia, audio, input, IO e memoria. -- **Manutenibilidade:** baixa para v1; mistura a decisao de foreground com multitarefa real. - -## Sugestao / Recomendacao - -Recomendo seguir com a combinacao das opcoes A e B: - -- somente um Game residente por vez; -- somente um Shell foreground por vez; -- Game pode ficar pausado/suspenso enquanto Shell esta foreground; -- Shell VM-backed pode existir junto com Game pausado, mas segue lifecycle Shell; -- somente o owner foreground executa VM/tick/event loop conforme sua politica; -- background execution fica explicitamente fora de escopo; -- render ownership muda junto com foreground owner e deve invalidar submissions antigas via epoch/generation, conforme `DSC-0040`. - -Isso preserva o ciclo `Game -> Home/Shell -> Game` sem transformar o runtime em multitarefa geral agora. - -## Perguntas em Aberto - -- [ ] Qual e o nome canonico dos estados de Game fora do foreground: `Paused`, `Suspended`, ambos, ou outro? -- [ ] Ao apertar Home, o Game deve receber um evento/interrupt/trap de pausa antes de parar de executar? -- [ ] O Hub e um Shell especial sempre presente ou uma task Shell comum? -- [ ] Um app Shell VM-backed pode substituir o Hub no foreground ou o Hub permanece como owner raiz? -- [ ] Quando o Shell VM-backed fecha, o retorno vai sempre para Hub ou pode voltar diretamente ao Game? -- [ ] O input do Game e drenado/limpo ao pausar e ao retomar? -- [ ] Audio do Game pausa junto com a VM ou tem politica propria? -- [ ] Existe limite de memoria/residencia para manter Game pausado enquanto Shell app roda? -- [ ] O retorno ao Game exige novo frame antes de trocar a superficie, ou a tela pode manter Hub/Shell ate a primeira submission valida? -- [ ] Quais eventos de lifecycle precisam ser visiveis para firmware, OS, VM e host? - -## Criterio para Encerrar - -A agenda pode ser encerrada quando tivermos uma decisao clara sobre: - -- cardinalidade de Games e Shells residentes/foreground; -- semantica de Game pausado/suspenso; -- politica de execucao para Shell VM-backed durante Game pausado; -- regra de foreground visual owner e render invalidation; -- comportamento minimo do ciclo `Game -> Shell -> Game`; -- itens explicitamente fora de escopo, especialmente background execution real. - -## Discussao - -- 2026-06-05: Agenda criada a partir da `DSC-0040`, quando o caso de Shell VM-backed aberto sobre um Game pausado mostrou que a discussao pertence ao lifecycle/SystemOS, nao apenas ao render worker. diff --git a/discussion/workflow/agendas/AGD-0045-systemos-library-root-home-game-launch.md b/discussion/workflow/agendas/AGD-0045-systemos-library-root-home-game-launch.md deleted file mode 100644 index 3f1980f7..00000000 --- a/discussion/workflow/agendas/AGD-0045-systemos-library-root-home-game-launch.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -id: AGD-0045 -ticket: system-run-cart -title: SystemOS Library Root and Home Game Launch -status: accepted -created: 2026-07-03 -resolved: -decision: -tags: [runtime, os, hub, cartridge, launcher, library, game, architecture] ---- - -## Contexto - -A `DEC-0035` fechou que `system.run_cart` nao deve existir como syscall -userland e que boot direto por host/CLI/debug deve permanecer. - -Isso resolveu a autoridade negativa, mas ainda nao resolveu a experiencia -desejada para o SO: apontar o runtime para um diretorio raiz de biblioteca, -fazer a Home listar os cartuchos encontrados ali e permitir que o usuario clique -em um jogo para carrega-lo. - -O objetivo pratico e um "Switch feio": uma lista simples na Home, sem UX final, -sem marketplace e sem catalogo rico, mas capaz de provar que o SO, e nao o -guest, escolhe e carrega um cartucho a partir de uma biblioteca local. - -## Problema - -Hoje temos dois caminhos separados: - -- boot direto por `--run ` funciona e carrega um cartucho unico; -- Hub/Home existe, mas mostra acoes fixas de Shell nativo e nao lista jogos de - uma biblioteca real. - -Sem uma biblioteca de cartuchos visivel para o SO, o usuario nao consegue -iniciar jogos a partir da Home. Isso deixa a agenda `system-run-cart` incompleta -do ponto de vista de produto: removemos o `run_cart` userland, mas ainda falta a -alternativa correta, que e o SO iniciar cartuchos por acao de usuario. - -## Pontos Criticos - -- O root da biblioteca deve ser fornecido pelo host como `--games-root `. -- O scan inicial deve cobrir subdiretorios imediatos com `manifest.json`. -- O loader de diretorio existente deve continuar sendo a autoridade para validar - cartucho completo; a biblioteca pode ler metadados minimos para listagem. -- A Home pode mostrar uma lista feia/clicavel; polimento visual fica fora. -- O clique em um Game deve iniciar `LoadCartridge` pelo firmware/SystemOS, nao - por syscall guest. -- V1 nao precisa tratar retorno de um Game para Home, porque isso pertence a - `AGD-0041`. -- `.pmc`, outros tipos de app, catalogo remoto, marketplace, icones, busca e - metadados ricos ficam fora. - -## Opcoes - -### Opcao A - Biblioteca apenas para listagem - -- **Abordagem:** adicionar `--games-root`, escanear manifestos e mostrar jogos - na Home, mas sem permitir launch ainda. -- **Pro:** baixa complexidade; prova descoberta/catalogo local. -- **Contra:** nao entrega o objetivo principal de carregar jogo pela Home. -- **Manutenibilidade:** boa, mas valor funcional limitado. - -### Opcao B - Home lista biblioteca e carrega Game quando nao ha Game ativo - -- **Abordagem:** adicionar `--games-root`, escanear cartuchos locais, renderizar - lista simples na Home e permitir clique para carregar um Game via - `LoadCartridge`. A primeira versao cobre Home inicial/sem Game residente. -- **Pro:** entrega o fluxo desejado sem abrir troca completa entre jogos. -- **Contra:** exige passar biblioteca/acao selecionada entre host, Hub, - firmware e loader. -- **Manutenibilidade:** boa se a fronteira ficar clara: biblioteca descobre, - Hub escolhe, firmware carrega. - -### Opcao C - Biblioteca mais troca completa entre jogos - -- **Abordagem:** Home lista biblioteca e tambem permite `Game A -> Home -> - Game B` ja no primeiro passo. -- **Pro:** cobre a experiencia final. -- **Contra:** mistura biblioteca com pausa, fechamento, cleanup e falha de load; - depende de `AGD-0041` e `AGD-0044`. -- **Manutenibilidade:** baixa para v1. - -## Sugestao / Recomendacao - -Recomendo a Opcao B. - -O v1 deve permitir: - -- host recebe `--games-root `; -- o SO/Hub recebe uma lista de cartuchos descobertos; -- a Home mostra uma lista simples/clicavel de jogos; -- clicar em um Game carrega o cartucho via firmware `LoadCartridge`; -- boot direto por `--run ` continua separado e intacto; -- nenhum guest/app chama `run_cart`; -- o escopo v1 acontece na Home inicial; retorno de Game para Home fica para - `AGD-0041`, e troca `Game A -> Home -> Game B` fica para `AGD-0044`. - -Detalhes aceitos para v1: - -- scan apenas de subdiretorios imediatos; -- somente cartuchos `app_mode: Game`; -- `.pmc` fica para futuro; -- cartuchos invalidos sao logados e omitidos da lista; -- o modelo interno deve ser uma entry de biblioteca expansivel, nao apenas um - summary de UI. A entry pode manter o manifest carregado em memoria, path - interno necessario para launch e metadados operacionais como instante de - descoberta/instalacao. A UI v1 mostra apenas `title`, `app_id` e - `app_version`, mas futuros metadados ingeridos pela biblioteca poderao ser - usados pelo catalogo e pela propria UI; -- clique carrega imediatamente, sem confirmacao; -- falha de load volta para Home e registra log/erro. - -Essa agenda deve produzir a alternativa correta ao `run_cart` userland: uma -acao de sistema, iniciada pela Home, que carrega cartucho escolhido pelo usuario -a partir de uma biblioteca local. - -## Perguntas em Aberto - -- [x] O argumento deve se chamar `--games-root`. -- [x] A biblioteca deve escanear apenas subdiretorios imediatos. -- [x] A listagem v1 deve incluir apenas `app_mode: Game`. -- [x] O scan deve logar cartuchos invalidos e omiti-los da lista. -- [x] O Hub/SystemOS deve receber uma entry de biblioteca expansivel, capaz de - manter o manifest carregado, path interno para launch e metadados - operacionais como timestamp de descoberta/instalacao. A UI v1 so precisa - mostrar `title`, `app_id` e `app_version`. -- [x] O clique deve carregar imediatamente, sem confirmacao. -- [x] Se um cartucho falha ao carregar, o sistema volta para Home e registra - erro/log. -- [x] Retorno de Game para Home nao pertence a esta agenda; fica para - `AGD-0041`. -- [x] O teste minimo deve provar o fluxo `games-root -> Home list -> click -> - LoadCartridge -> GameRunning`? - -## Criterio para Encerrar - -A agenda pode virar decisao quando estiver claro: - -- nome e semantica do argumento de host para root da biblioteca; -- escopo do scan e metadados minimos; -- fronteira entre biblioteca, Hub, firmware e loader; -- politica v1 para cartuchos invalidos; -- regra v1 para launch quando nao ha Game ativo; -- fronteira explicita com `AGD-0041` e `AGD-0044`; -- testes minimos para discovery e launch pela Home. - -## Discussao - -- 2026-07-03: Agenda criada como continuacao da `DSC-0004` apos a `DEC-0035`. - A decisao anterior removeu `run_cart` userland; esta agenda define o caminho - correto para o SO/Home carregar jogos a partir de uma biblioteca local. -- 2026-07-03: Respostas aceitas: usar `--games-root`, escanear apenas - subdiretorios imediatos, listar somente Games, deixar `.pmc` e outros apps - para depois, logar/omitir invalidos, usar summary com `title`, `app_id` e - `app_version`, carregar imediatamente no clique, e deixar retorno para Home - sob responsabilidade da `AGD-0041`. -- 2026-07-03: Modelo de dados refinado: a biblioteca deve produzir entries - expansíveis, nao somente summaries de UI. Cada entry pode manter manifest em - memoria, path interno de launch e metadados operacionais como timestamp de - descoberta/instalacao; a UI v1 escolhe quais campos mostrar, mas o modelo - deve permitir que metadados futuros sejam ingeridos e usados pelo catalogo/UI. diff --git a/discussion/workflow/decisions/DEC-0035-remove-userland-system-run-cart-and-preserve-direct-boot.md b/discussion/workflow/decisions/DEC-0035-remove-userland-system-run-cart-and-preserve-direct-boot.md deleted file mode 100644 index 5e212b26..00000000 --- a/discussion/workflow/decisions/DEC-0035-remove-userland-system-run-cart-and-preserve-direct-boot.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -id: DEC-0035 -ticket: system-run-cart -title: Remove Userland system.run_cart and Preserve Direct Boot -status: accepted -created: 2026-07-03 -ref_agenda: AGD-0003 -tags: [] ---- - -## Status - -Accepted. - -## Contexto - -`system.run_cart` existe na ABI publica, mas nao possui comportamento real de -troca de cartucho. Isso cria uma falsa promessa: um guest pode declarar/chamar -uma operacao que aparenta iniciar outro cartucho, enquanto o runtime retorna -sucesso vazio ou nao executa uma transicao completa. - -A discussao fechou tres separacoes: - -- boot direto por host/CLI/debug e necessario e deve permanecer; -- apps/userland nao devem iniciar cartuchos diretamente; -- troca `Game A -> Home -> Game B` exige um orquestrador de SystemOS proprio, - separado em `DSC-0043 / AGD-0044`. - -`AGD-0041` permanece responsavel por `Game -> Home -> mesmo Game`, foreground, -pausa/suspensao e retorno ao mesmo Game. - -## Decisao - -`system.run_cart` MUST be removed from the userland guest ABI. - -No app, cartridge, Shell app, or other userland guest code MAY directly request -cartridge boot through `system.run_cart`. - -The platform MUST NOT preserve compatibility for the current userland -`system.run_cart` surface. It MUST NOT remain as a callable stub, reserved -guest syscall that returns success, or capability-gated userland escape hatch. - -Direct boot MUST remain supported through host/CLI/debug or equivalent -single-game launch paths. This direct boot path is a host/system entrypoint, -not a guest syscall. - -Internal cartridge loading MAY continue to use the existing loader path, -including directory cartridges and test fixtures. `.pmc` support is not required -by this decision. - -If apps ever need to request navigation in the future, that MUST be designed as -a separate SystemOS-mediated surface with different naming and semantics. It -MUST NOT reintroduce userland `run_cart` as direct cartridge boot. - -## Rationale - -Direct cartridge boot is essential for development, debugging, automated tests, -and future distribution modes where the runtime launches as a single game. That -use case does not require a guest-visible syscall. - -Userland cartridge boot would give app code authority over system navigation, -lifecycle, cleanup, target resolution, and failure policy. Those concerns belong -to SystemOS/firmware and later to the cartridge switch orchestrator, not to a VM -guest syscall. - -Keeping a stub or compatibility surface would preserve the current ambiguity. -The public ABI should not advertise an operation that either does nothing or -looks like direct app-controlled navigation. - -## Invariantes / Contrato - -- Guest/userland MUST NOT expose or call `system.run_cart`. -- The public syscall registry, declarations, generated bindings, and docs MUST - stop presenting `system.run_cart` as an app-callable operation. -- Booting a cartridge directly MUST remain available from host/CLI/debug flows. -- Direct boot MUST enter the runtime with a selected cartridge target; it MUST - NOT depend on a guest syscall issued after VM startup. -- Loader code MAY remain reusable by host/firmware/SystemOS internals. -- Loader code MUST NOT become lifecycle orchestration authority. -- `Game -> Home -> same Game` remains owned by `AGD-0041`. -- `Game A -> Home -> Game B` remains owned by `DSC-0043 / AGD-0044`. -- `.pmc`, app catalog, launcher UX, marketplace/distribution, and full cartridge - replacement under Home are outside this decision. - -## Impactos - -- **Spec / ABI:** remove or retire `system.run_cart` from the userland syscall - surface. Any syscall table, PBS declarations, generated metadata, or docs - must stop advertising it to apps. -- **Runtime / VM:** guest dispatch must no longer treat `system.run_cart` as a - valid userland operation. If obsolete bytecode or declarations still reach the - runtime during migration, they must fail explicitly rather than report success. -- **Firmware / Host:** direct boot remains supported for command-line, - debugger, test harness, and future single-game launch flows. -- **SystemOS:** no new cart-switch orchestrator is required by this decision. - That work is deferred to `DSC-0043 / AGD-0044`. -- **Tooling:** tools must continue to support direct cartridge launch for - development and test workflows, without relying on a guest syscall. -- **Tests:** coverage must prove both sides: userland cannot declare/call - `system.run_cart`, and direct boot of a cartridge still works. - -## Alternativas Descartadas - -- **Implement `system.run_cart` as a normal syscall:** rejected because it gives - userland direct navigation authority and mixes guest execution with system - lifecycle. -- **Keep a compatibility stub:** rejected because no compatibility is required - and a stub preserves the false contract. -- **Make `system.run_cart` privileged but still guest-visible:** rejected for v1 - because it keeps the ambiguous idea that some guest profile may directly boot - cartuchos. -- **Implement full `Home -> another Game` now:** rejected for this decision; it - requires SystemOS orchestration after the `AGD-0041` foreground contract. - -## Referencias - -- Agenda: `AGD-0003` -- Foreground/pause scope: `AGD-0041` -- Future cartridge switch orchestrator: `DSC-0043 / AGD-0044` -- Related lessons: `LSN-0041` SystemOS lifecycle authority, `LSN-0042` - SystemOS service ownership boundary, `LSN-0043` SystemOS domain facades. - -## Propagacao Necessaria - -- Remove `SystemRunCart` from userland ABI declarations and generated syscall - metadata. -- Remove or rewrite runtime dispatch paths that return success for - `system.run_cart`. -- Preserve host/CLI/debug direct boot paths and add regression tests for them. -- Add negative tests for guest/userland declaration or call attempts. -- Update any documentation/spec text that describes `system.run_cart` as - app-callable. - -## Revision Log - -- 2026-07-03: Initial draft from `AGD-0003`. diff --git a/discussion/workflow/decisions/DEC-0036-systemos-games-root-and-home-game-launch.md b/discussion/workflow/decisions/DEC-0036-systemos-games-root-and-home-game-launch.md deleted file mode 100644 index ed7eda2c..00000000 --- a/discussion/workflow/decisions/DEC-0036-systemos-games-root-and-home-game-launch.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -id: DEC-0036 -ticket: system-run-cart -title: SystemOS Games Root and Home Game Launch -status: accepted -created: 2026-07-03 -ref_agenda: AGD-0045 -tags: [] ---- - -## Status - -Accepted. - -## Contexto - -`DEC-0035` removed `system.run_cart` from the userland guest ABI and preserved -direct host/CLI/debug cartridge boot. That decision intentionally did not -deliver the Home/SO product flow: pointing PROMETEU at a local games directory, -listing available games in Home, and launching a selected game by user action. - -This decision defines the first system-owned replacement for userland -`run_cart`: a local games-root library discovered by the host/SystemOS and -exposed through a simple Home UI. - -## Decisao - -PROMETEU MUST support a host-provided games library root through -`--games-root `. - -The games root is a directory containing game cartridge directories as immediate -children. V1 MUST scan only immediate child directories. Recursive discovery is -out of scope. - -The v1 games library MUST include only cartridges whose manifest declares -`app_mode: Game`. Shell/System apps, `.pmc` packages, remote catalogs, -marketplace/distribution metadata, rich icons, search, and non-game apps are out -of scope. - -The library MUST produce expandable internal entries, not UI-only summaries. -Each entry MUST retain enough internal data to launch the cartridge and to grow -as a catalog model. At minimum, each entry MUST include: - -- loaded manifest data; -- `title`; -- `app_id`; -- `app_version`; -- internal cartridge path; -- operational discovery metadata, including a timestamp or equivalent discovery - marker. - -The Home UI v1 SHOULD display only `title`, `app_id`, and `app_version`. It MAY -hide path and operational metadata. - -Invalid cartridge candidates MUST be logged and omitted from the Home game list. - -Selecting a listed Game in Home MUST immediately request a system-owned launch -through firmware/SystemOS, resulting in `LoadCartridge` for that cartridge. This -MUST NOT use a guest syscall and MUST NOT reintroduce userland `run_cart`. - -If launch fails, the machine MUST return to or remain in Home and record an -error/log. A polished error UI is out of scope. - -Booting directly with `--run ` MUST remain separate and unchanged. - -Returning from a running Game to Home is outside this decision and belongs to -`AGD-0041`. Switching from `Game A -> Home -> Game B` after a game is already -active belongs to `DSC-0043 / AGD-0044`. - -## Rationale - -The previous `run_cart` surface was wrong because it made cartridge launch look -like an app/userland capability. The desired behavior is still valid, but the -authority must live in the system: Home presents available games and the -firmware/SystemOS loads the selected cartridge. - -Using `--games-root` keeps the v1 product model honest: this is a local game -library, not a general app root. Other app types can be introduced later without -forcing the first launcher model to pretend it is a full marketplace. - -Immediate child scanning keeps discovery deterministic and testable. `.pmc` and -recursive layouts can be added after the directory-cartridge path is proven. - -An expandable entry model avoids prematurely freezing the UI shape as the data -model. The UI is intentionally simple, but the catalog model needs room for -future metadata such as package origin, installation/discovery timestamps, rich -presentation fields, and packaged cartridge details. - -## Invariantes / Contrato - -- `--games-root ` is the v1 host argument for a local games library. -- The games root is Game-only in v1. -- Discovery scans immediate child directories only. -- Directory candidates without valid Game cartridge metadata are logged and - omitted. -- Library entries are internal catalog records, not direct UI DTOs. -- The UI v1 displays a simple clickable list and may show only `title`, - `app_id`, and `app_version`. -- The internal entry retains cartridge path and loaded manifest data for launch. -- Home selection is a system action that enters firmware/SystemOS loading. It is - not a guest syscall. -- `--run ` direct boot remains independent from `--games-root`. -- Return-to-Home and game-to-game switching are not implemented by this - decision. - -## Impactos - -- **Host:** add a `--games-root ` argument and pass the resolved root into - runtime/firmware/SystemOS initialization. -- **SystemOS / Hub:** add a local game library model and expose entries to Home. - Home must render a minimal clickable list and emit a system-owned launch - action for a selected entry. -- **Firmware:** consume the Home launch action and transition to - `LoadCartridge` with the selected cartridge path. -- **Loader:** continue to validate complete directory cartridges. Discovery may - read manifest metadata, but full launch still uses the existing loader. -- **Runtime / VM:** no userland ABI is added. No guest syscall participates in - launch. -- **Specs / Docs:** document `--games-root`, Game-only v1 discovery, and the - distinction between direct boot, games library launch, and future game - switching. -- **Tests:** cover discovery, invalid omission/logging, Home action emission, - and the end-to-end path from games root selection to `GameRunning`. - -## Alternativas Descartadas - -- **List only, no launch:** rejected because it does not satisfy the product - goal of starting games from Home. -- **General app root:** rejected for v1 because Shell/System apps need separate - lifecycle and UI contracts. -- **Recursive discovery:** rejected for v1 to keep semantics deterministic. -- **`.pmc` discovery:** rejected for v1 because packed cartridge loading is a - separate open agenda. -- **Game-to-game switch now:** rejected because it depends on foreground and - cartridge-switch orchestration work in `AGD-0041` and `AGD-0044`. - -## Referencias - -- Source agenda: `AGD-0045` -- Userland run-cart removal: `DEC-0035` -- Foreground/Home return scope: `AGD-0041` -- Future cartridge switch orchestration: `DSC-0043 / AGD-0044` - -## Propagacao Necessaria - -- Add or update runtime specs for games-root discovery and Home launch. -- Add host CLI argument plumbing for `--games-root`. -- Add a catalog/library entry model. -- Add Hub/Home list rendering and click handling. -- Add firmware/SystemOS action routing to `LoadCartridge`. -- Add tests for discovery, invalid candidates, action emission, and - `games-root -> Home click -> GameRunning`. - -## Revision Log - -- 2026-07-03: Initial draft from `AGD-0045`. diff --git a/discussion/workflow/plans/PLN-0129-remove-userland-run-cart-abi-surface.md b/discussion/workflow/plans/PLN-0129-remove-userland-run-cart-abi-surface.md deleted file mode 100644 index 52c071ae..00000000 --- a/discussion/workflow/plans/PLN-0129-remove-userland-run-cart-abi-surface.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -id: PLN-0129 -ticket: system-run-cart -title: Remove Userland Run Cart ABI Surface -status: done -created: 2026-07-03 -ref_decisions: [DEC-0035] -tags: [] ---- - -## Briefing - -`DEC-0035` removes `system.run_cart` from the userland guest ABI. This plan -covers the public ABI, syscall registry/declaration surface, generated metadata, -and documentation/spec propagation. It does not change runtime dispatch behavior -or direct boot tests; those are covered by `PLN-0130` and `PLN-0131`. - -## Objective - -Remove `system.run_cart` from every app-callable/userland ABI surface while -preserving non-guest direct boot entrypoints. - -## Dependencies - -- Source decision: `DEC-0035`. -- Must stay scoped away from `AGD-0041` and `AGD-0044`. -- Coordinate with `PLN-0130` so runtime dispatch does not retain a callable - success path after the declaration is removed. - -## Scope - -- Remove or retire `Syscall::SystemRunCart` from userland-visible syscall - declarations in `crates/console/prometeu-hal/src/syscalls.rs`. -- Remove the `system.run_cart` registry entry from - `crates/console/prometeu-hal/src/syscalls/domains/system.rs` and - `crates/console/prometeu-hal/src/syscalls/registry.rs`, unless implementation - discovers an internal-only registry distinct from guest ABI. -- Remove generated/static bytecode or VM-facing declarations that expose - `system.run_cart`, including known references in - `crates/console/prometeu-bytecode/src/model.rs` and - `crates/console/prometeu-vm/src/virtual_machine.rs` if they include it. -- Update docs/specs that mention `system.run_cart` as app-callable. -- Update `AGD-0004` references only if they become factually misleading after - removal; do not close or reinterpret that agenda. - -## Non-Goals - -- Do not implement `Home -> another Game`. -- Do not add a replacement app navigation syscall. -- Do not implement `.pmc`. -- Do not change direct host/CLI/debug boot behavior. -- Do not add compatibility stubs for obsolete guest code. - -## Execution Method - -1. Search for `SystemRunCart`, `run_cart`, and `"system", "run_cart"` across - `crates/`, `docs/`, and `discussion/`. -2. Remove `system.run_cart` from userland syscall domain declarations and - lookup/name registries. -3. Remove generated/static guest-facing metadata entries that advertise - `system.run_cart`. -4. Update docs/specs to state that cartridges are booted by host/system paths, - not by guest syscall. -5. Compile the workspace or targeted crates to find stale references. - -## Acceptance Criteria - -- No userland ABI declaration advertises `system.run_cart`. -- No generated guest metadata exposes `run_cart` as callable. -- Documentation/spec text no longer describes app-callable cartridge boot. -- Any retained code path is clearly internal-only and is not reachable through - guest syscall lookup. - -## Tests - -- Add or update syscall registry tests to assert `system.run_cart` is absent - from the public/userland registry. -- Run targeted tests for `prometeu-hal`, `prometeu-bytecode`, and - `prometeu-vm` if available. -- Run repository validation commands used for syscall declaration consistency. - -## Affected Artifacts - -- `crates/console/prometeu-hal/src/syscalls.rs` -- `crates/console/prometeu-hal/src/syscalls/domains/system.rs` -- `crates/console/prometeu-hal/src/syscalls/registry.rs` -- `crates/console/prometeu-bytecode/src/model.rs` -- `crates/console/prometeu-vm/src/virtual_machine.rs` -- `docs/specs/runtime/` -- `discussion/workflow/agendas/AGD-0004-system-fault-semantics-and-control-surface.md` diff --git a/discussion/workflow/plans/PLN-0130-remove-runtime-run-cart-stub-dispatch.md b/discussion/workflow/plans/PLN-0130-remove-runtime-run-cart-stub-dispatch.md deleted file mode 100644 index 71be6a70..00000000 --- a/discussion/workflow/plans/PLN-0130-remove-runtime-run-cart-stub-dispatch.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -id: PLN-0130 -ticket: system-run-cart -title: Remove Runtime Run Cart Stub Dispatch -status: done -created: 2026-07-03 -ref_decisions: [DEC-0035] -tags: [] ---- - -## Briefing - -`DEC-0035` forbids a callable userland `system.run_cart` stub. This plan covers -runtime and VM dispatch cleanup so obsolete calls cannot silently succeed. -Public ABI removal is handled by `PLN-0129`; direct boot preservation is handled -by `PLN-0131`. - -## Objective - -Remove the runtime no-op dispatch path for `SystemRunCart` and ensure stale or -obsolete userland attempts fail explicitly rather than returning success. - -## Dependencies - -- Source decision: `DEC-0035`. -- Depends on the ABI direction in `PLN-0129`, but can be implemented in the same - PR if the public enum/registry removal and dispatch cleanup move together. - -## Scope - -- Remove the `Syscall::SystemRunCart => return Ok(())` path from - `crates/console/prometeu-system/src/services/vm_runtime/dispatch.rs`. -- Update any match arms made unreachable or obsolete by removing - `SystemRunCart`. -- Ensure stale numeric syscall ids or stale bytecode declarations fail with the - repository's existing unknown/invalid syscall behavior. -- Add negative coverage for userland attempts to invoke the removed operation, - using the closest existing VM runtime syscall tests. - -## Non-Goals - -- Do not implement a replacement navigation operation. -- Do not implement the `Home -> another Game` orchestrator. -- Do not alter direct host/CLI/debug cartridge boot paths. -- Do not change `system.has_cart` behavior unless required by compile fallout. - -## Execution Method - -1. Inspect `dispatch.rs` for all `SystemRunCart` match arms. -2. Remove the no-op success path and any unreachable arms tied only to - `SystemRunCart`. -3. If the public enum variant is removed by `PLN-0129`, update exhaustiveness - and registry mapping accordingly. -4. Add or update VM runtime tests that demonstrate `system.run_cart` is not - callable from guest/userland. -5. Run targeted runtime tests and compile checks. - -## Acceptance Criteria - -- There is no runtime path where a guest `system.run_cart` call returns `Ok(())`. -- Stale/obsolete attempts fail explicitly through existing invalid syscall or - declaration validation behavior. -- `system.has_cart` and unrelated system syscalls keep their current behavior. -- Runtime dispatch remains exhaustive and clear after removing `SystemRunCart`. - -## Tests - -- Add a negative test in `crates/console/prometeu-system/src/services/vm_runtime` - for stale userland `run_cart` attempts, if the test harness can express it. -- Run `cargo test` for `prometeu-system` and any impacted syscall crates. -- Run a workspace compile/test command if targeted tests reveal cross-crate enum - fallout. - -## Affected Artifacts - -- `crates/console/prometeu-system/src/services/vm_runtime/dispatch.rs` -- `crates/console/prometeu-system/src/services/vm_runtime/tests.rs` -- `crates/console/prometeu-hal/src/syscalls.rs` -- `crates/console/prometeu-hal/src/syscalls/registry.rs` diff --git a/discussion/workflow/plans/PLN-0131-preserve-direct-cartridge-boot-coverage.md b/discussion/workflow/plans/PLN-0131-preserve-direct-cartridge-boot-coverage.md deleted file mode 100644 index 75660a8c..00000000 --- a/discussion/workflow/plans/PLN-0131-preserve-direct-cartridge-boot-coverage.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -id: PLN-0131 -ticket: system-run-cart -title: Preserve Direct Cartridge Boot Coverage -status: done -created: 2026-07-03 -ref_decisions: [DEC-0035] -tags: [] ---- - -## Briefing - -`DEC-0035` removes userland `system.run_cart`, but direct cartridge boot remains -mandatory for development, debugging, automated tests, and future single-game -launch scenarios. This plan protects that path while the guest syscall is -removed. - -## Objective - -Preserve and test host/CLI/debug direct cartridge boot without relying on a -guest syscall. - -## Dependencies - -- Source decision: `DEC-0035`. -- Can be implemented after or alongside `PLN-0129` and `PLN-0130`. -- Must not depend on `.pmc`, app catalog, launcher UX, `AGD-0041`, or - `AGD-0044`. - -## Scope - -- Identify direct boot entrypoints in `crates/tools/prometeu-cli/src/main.rs`, - `crates/host/prometeu-host-desktop-winit/src/lib.rs`, - `crates/host/prometeu-host-desktop-winit/src/debugger.rs`, and firmware - cartridge loading code. -- Add or preserve tests showing a cartridge can be loaded directly into - firmware/runtime from host or CLI paths. -- Ensure these paths do not call or require `system.run_cart`. -- Document the distinction between direct boot and guest navigation where - runtime specs describe boot behavior. - -## Non-Goals - -- Do not implement `Home -> another Game`. -- Do not add app-callable navigation. -- Do not require `.pmc`. -- Do not redesign cartridge loader internals beyond what is required to keep - direct boot working. - -## Execution Method - -1. Trace the CLI/debug direct boot path from cartridge path argument through - loader, firmware `load_cartridge`, and runtime initialization. -2. Add regression tests at the lowest practical layer proving direct boot still - reaches `GameRunning` for a valid game cartridge. -3. Add a guard or assertion that direct boot does not depend on `system.run_cart` - metadata. -4. Update docs/specs to describe direct boot as host/system-controlled. -5. Run targeted CLI/firmware/host tests. - -## Acceptance Criteria - -- A valid game cartridge can still boot directly from host/CLI/debug paths. -- Direct boot does not depend on `system.run_cart` being in the guest syscall - registry. -- Existing debugger/handshake metadata that reports cartridge info still works. -- The direct boot behavior is documented as host/system entry, not guest ABI. - -## Tests - -- Add or preserve firmware tests around `load_cartridge` and `GameRunning`. -- Add or preserve host/CLI tests for cartridge path boot when practical. -- Run `cargo test` for `prometeu-firmware`, `prometeu-host-desktop-winit`, and - `prometeu-cli` where available. - -## Affected Artifacts - -- `crates/tools/prometeu-cli/src/main.rs` -- `crates/host/prometeu-host-desktop-winit/src/lib.rs` -- `crates/host/prometeu-host-desktop-winit/src/debugger.rs` -- `crates/console/prometeu-firmware/src/firmware/firmware.rs` -- `crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs` -- `docs/specs/runtime/` diff --git a/discussion/workflow/plans/PLN-0132-specify-games-root-home-launch-contract.md b/discussion/workflow/plans/PLN-0132-specify-games-root-home-launch-contract.md deleted file mode 100644 index 7bd714c1..00000000 --- a/discussion/workflow/plans/PLN-0132-specify-games-root-home-launch-contract.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -id: PLN-0132 -ticket: system-run-cart -title: Specify Games Root Home Launch Contract -status: done -created: 2026-07-03 -ref_decisions: [DEC-0036] -tags: [] ---- - -## Briefing - -`DEC-0036` establishes a new system-owned game library launch path: -the host receives `--games-root `, SystemOS/Home lists valid Game -directory cartridges from that root, and selecting one starts the cartridge -through firmware loading rather than a guest syscall. - -This plan updates the normative specs before code is changed. - -## Objective - -Document the v1 games-root and Home launch contract in the canonical runtime -specs so implementation plans cannot drift back into userland `run_cart` or -general app-library semantics. - -## Dependencies - -- Source decision: `DEC-0036`. -- Prior decision: `DEC-0035`, which removed userland `system.run_cart` and - preserved direct `--run` boot. -- This plan must be completed before `PLN-0133`, `PLN-0134`, and `PLN-0135`. - -## Scope - -- Update the runtime specs to define `--games-root ` as the v1 local games - library root. -- Specify that discovery scans only immediate child directories. -- Specify that v1 library discovery includes only valid directory cartridges - with `app_mode: Game`. -- Specify that `.pmc`, recursive discovery, Shell/System apps, marketplace - metadata, rich presentation, search, and non-game apps are out of scope. -- Specify the internal library entry contract: loaded manifest data, `title`, - `app_id`, `app_version`, internal cartridge path, and discovery metadata. -- Specify that Home v1 may display only `title`, `app_id`, and `app_version`. -- Specify that selecting a Home game emits a system-owned launch request that - transitions through firmware/SystemOS into `LoadCartridge`. -- Specify that invalid candidates are logged and omitted. -- Specify that launch failure remains or returns to Home and logs the failure. -- Reaffirm that direct `--run ` boot remains independent and unchanged. - -## Non-Goals - -- No Rust implementation. -- No Home UI changes. -- No `.pmc` discovery or packaged-cartridge spec changes. -- No recursive library layout. -- No return-to-Home behavior for a running Game. -- No Game A -> Home -> Game B orchestration. -- No guest syscall or userland ABI addition. - -## Execution Method - -1. Update [docs/specs/runtime/14-boot-profiles.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/14-boot-profiles.md) with: - - direct `--run` boot remaining a single-cartridge boot profile; - - `--games-root ` as a SystemOS/Home library profile; - - the distinction between direct boot, Home launch, and future game-switch - orchestration. -2. Update [docs/specs/runtime/12-firmware-pos-and-prometeuhub.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md) with: - - Home's responsibility to display the local games library; - - the system-owned launch action boundary; - - failure handling that leaves the machine in Home and records an error. -3. Update [docs/specs/runtime/13-cartridge.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/13-cartridge.md) with: - - directory-cartridge discovery requirements for library candidates; - - Game-only filtering by manifest `app_mode`; - - invalid candidate omission and diagnostics. -4. Update [docs/specs/runtime/16-host-abi-and-syscalls.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/16-host-abi-and-syscalls.md) only if needed to reinforce that no guest syscall owns cartridge launch. -5. Keep all spec text in English. - -## Acceptance Criteria - -- The specs define `--games-root ` and do not describe it as a generic app - root. -- The specs state that v1 scans immediate child directories only. -- The specs state that only valid Game directory cartridges enter the Home list. -- The specs state that Home launch is system-owned and enters `LoadCartridge`. -- The specs preserve `--run ` as a separate direct boot path. -- The specs explicitly exclude `.pmc`, recursive discovery, return-to-Home, and - game-switch orchestration from this implementation slice. -- No spec reintroduces userland `run_cart`. - -## Tests - -- Run `discussion validate`. -- Run targeted text checks for `--games-root`, `LoadCartridge`, and absence of - `system.run_cart` as an active public launch mechanism in edited specs. - -## Affected Artifacts - -- `docs/specs/runtime/12-firmware-pos-and-prometeuhub.md` -- `docs/specs/runtime/13-cartridge.md` -- `docs/specs/runtime/14-boot-profiles.md` -- `docs/specs/runtime/16-host-abi-and-syscalls.md` -- `discussion/workflow/plans/PLN-0132-specify-games-root-home-launch-contract.md` diff --git a/discussion/workflow/plans/PLN-0133-implement-games-root-library-discovery.md b/discussion/workflow/plans/PLN-0133-implement-games-root-library-discovery.md deleted file mode 100644 index 65bc21f2..00000000 --- a/discussion/workflow/plans/PLN-0133-implement-games-root-library-discovery.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -id: PLN-0133 -ticket: system-run-cart -title: Implement Games Root Library Discovery -status: done -created: 2026-07-03 -ref_decisions: [DEC-0036] -tags: [] ---- - -## Briefing - -`DEC-0036` requires a host-provided games root and an expandable internal -library model before Home can render or launch games. This plan implements the -library discovery and host plumbing only; UI launch wiring is handled by -`PLN-0134`. - -## Objective - -Add a tested games-root discovery model that scans immediate child directory -cartridges, filters to valid Game manifests, records diagnostics for invalid -candidates, and carries enough metadata for Home rendering and later launch. - -## Dependencies - -- Source decision: `DEC-0036`. -- `PLN-0132` should be completed first so code follows the documented contract. -- Existing cartridge manifest and directory loader behavior in the console - crates. -- Existing host CLI and `HostRunner` boot setup. - -## Scope - -- Add `--games-root ` to the desktop host CLI. -- Pass the optional games root into the host runner / firmware initialization - path without changing direct `--run` or `--debug` boot behavior. -- Add a SystemOS-owned games library model for directory cartridge discovery. -- Scan only immediate child directories of the root. -- Read and retain manifest data for each valid candidate. -- Include only candidates whose manifest declares `app_mode: Game`. -- Retain internal cartridge path and operational discovery metadata on each - entry. -- Emit diagnostics for invalid, unreadable, or non-Game candidates and omit - them from the returned entries. -- Keep discovery independent from full cartridge launch: discovery reads - metadata; launch still uses the existing loader in `PLN-0134`. - -## Non-Goals - -- No Home list rendering. -- No click handling or `LoadCartridge` transition. -- No `.pmc` discovery. -- No recursive scan. -- No Shell/System app catalog. -- No marketplace metadata, icons, search, sorting UI, or polished errors. -- No new guest syscall. - -## Execution Method - -1. Introduce a library/discovery module in the SystemOS side, preferably under - `crates/console/prometeu-system/src/services/` unless the existing module - layout has a more local owner. -2. Define an internal entry type with at least: - - loaded cartridge manifest data; - - `title`; - - `app_id`; - - `app_version`; - - internal cartridge path; - - discovery metadata, using `SystemTime` or another explicit discovery - marker. -3. Define discovery diagnostics that can be logged by the caller and asserted in - tests. -4. Implement a scanner that: - - accepts an optional root path; - - returns an empty library when no root is configured; - - iterates only immediate child directories; - - parses `manifest.json` using structured manifest types, not ad hoc string - matching; - - filters to `app_mode: Game`; - - sorts entries deterministically for stable Home presentation and tests. -5. Add `--games-root ` to - [crates/host/prometeu-host-desktop-winit/src/lib.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/host/prometeu-host-desktop-winit/src/lib.rs) - and pass the path into - [crates/host/prometeu-host-desktop-winit/src/runner.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/host/prometeu-host-desktop-winit/src/runner.rs). -6. Store or expose the discovered library to firmware/SystemOS in a way that - `PLN-0134` can consume without re-scanning inside the Home click path. -7. Add focused unit tests using temporary cartridge roots with: - - one valid Game directory cartridge; - - one valid non-Game cartridge; - - one invalid directory; - - one nested directory that must not be discovered. - -## Acceptance Criteria - -- `--games-root ` is accepted by the desktop host CLI. -- Absence of `--games-root` leaves existing Hub boot behavior unchanged. -- Direct `--run ` and `--debug ` behavior remains unchanged. -- Discovery returns only immediate valid Game directory cartridges. -- Each library entry retains manifest data, path, title, app id, app version, - and discovery metadata. -- Invalid candidates are represented in diagnostics and excluded from entries. -- Discovery does not fully load or boot cartridge programs. - -## Tests - -- Add and run focused games-library discovery tests in the owning console crate. -- Run `cargo test -p prometeu-system` if the library lives in - `prometeu-system`. -- Run `cargo test -p prometeu-host-desktop-winit` for CLI/runner plumbing. -- Run `discussion validate`. - -## Affected Artifacts - -- `crates/console/prometeu-system/src/services/` -- `crates/console/prometeu-system/src/lib.rs` or module exports as needed -- `crates/host/prometeu-host-desktop-winit/src/lib.rs` -- `crates/host/prometeu-host-desktop-winit/src/runner.rs` -- `discussion/workflow/plans/PLN-0133-implement-games-root-library-discovery.md` diff --git a/discussion/workflow/plans/PLN-0134-wire-home-game-list-launch-flow.md b/discussion/workflow/plans/PLN-0134-wire-home-game-list-launch-flow.md deleted file mode 100644 index 001a2bf0..00000000 --- a/discussion/workflow/plans/PLN-0134-wire-home-game-list-launch-flow.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -id: PLN-0134 -ticket: system-run-cart -title: Wire Home Game List Launch Flow -status: done -created: 2026-07-03 -ref_decisions: [DEC-0036] -tags: [] ---- - -## Briefing - -After `PLN-0133`, SystemOS can discover a local Game library but Home still -does not present or launch those entries. This plan wires the discovered -library into Prometeu Hub/Home and routes a selected game to firmware -`LoadCartridge`. - -## Objective - -Make Home display a minimal clickable games list and launch the selected Game -through a system-owned firmware/SystemOS path, without reintroducing userland -`run_cart`. - -## Dependencies - -- Source decision: `DEC-0036`. -- `PLN-0132` for documented contract. -- `PLN-0133` for `--games-root` plumbing and the internal library entry model. -- Existing Prometeu Hub Home profile and firmware state transitions. - -## Scope - -- Provide the discovered games library to Prometeu Hub/Home. -- Render a minimal Home list showing `title`, `app_id`, and `app_version`. -- Add Home hit-testing/click handling for game entries. -- Extend the system profile action model with a system-owned game launch action. -- Handle that action in firmware/SystemOS by loading the selected cartridge via - the existing cartridge loader and entering `LoadCartridge`. -- On load failure, log/record the error and keep or return the machine to Home. -- Preserve existing native shell launch behavior. - -## Non-Goals - -- No polished launcher UI. -- No icons, screenshots, search, filtering, or marketplace metadata. -- No return-to-Home while a Game is running. -- No Game A -> Home -> Game B foreground orchestration. -- No `.pmc` launch support. -- No guest syscall. -- No direct-boot behavior changes. - -## Execution Method - -1. Extend the SystemOS/Hub action type currently represented by - `SystemProfileAction::LaunchNativeShell` with a game-launch action that - carries a stable reference to the selected library entry or cartridge path. -2. Update the Prometeu Hub state in - [crates/console/prometeu-system/src/programs/prometeu_hub.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-system/src/programs/prometeu_hub.rs) - or its local modules to accept the discovered game entries. -3. Render the Home games list with stable dimensions and simple clickable rows. - Rows must display only `title`, `app_id`, and `app_version` for v1. -4. Add hit-testing so selecting a row emits the game-launch action immediately. -5. Update - [crates/console/prometeu-firmware/src/firmware/steps/firmware_step_hub_home.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-firmware/src/firmware/steps/firmware_step_hub_home.rs) - to consume the new action. -6. In the firmware action handler: - - call the existing cartridge loader for the selected path; - - on success, return `FirmwareState::LoadCartridge(...)`; - - on failure, log/record the error and remain in the Home path. -7. Ensure shell launch still works exactly as before. -8. Add unit or integration tests for: - - Home list presentation data; - - click-to-action emission; - - firmware action handling success; - - firmware action handling failure. - -## Acceptance Criteria - -- Starting the host with `--games-root ` exposes valid Game entries in - Home. -- The Home list displays `title`, `app_id`, and `app_version`. -- Clicking a listed Game routes through a system action and enters - `LoadCartridge`. -- The launch path does not use a guest syscall and does not mention - `system.run_cart`. -- Invalid or failing launch paths remain in Home and record an error. -- Existing native shell Home actions continue to work. -- Direct `--run` remains separate and unchanged. - -## Tests - -- Run `cargo test -p prometeu-system` for Hub action and presentation behavior. -- Run `cargo test -p prometeu-firmware` for firmware transition behavior. -- Run `cargo test -p prometeu-host-desktop-winit` if runner integration changes. -- Run `discussion validate`. - -## Affected Artifacts - -- `crates/console/prometeu-system/src/programs/prometeu_hub.rs` -- `crates/console/prometeu-system/src/services/` -- `crates/console/prometeu-firmware/src/firmware/steps/firmware_step_hub_home.rs` -- `crates/console/prometeu-firmware/src/firmware/firmware.rs` -- `crates/host/prometeu-host-desktop-winit/src/runner.rs` -- `discussion/workflow/plans/PLN-0134-wire-home-game-list-launch-flow.md` diff --git a/discussion/workflow/plans/PLN-0135-validate-games-root-launch-end-to-end.md b/discussion/workflow/plans/PLN-0135-validate-games-root-launch-end-to-end.md deleted file mode 100644 index 95721ff1..00000000 --- a/discussion/workflow/plans/PLN-0135-validate-games-root-launch-end-to-end.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -id: PLN-0135 -ticket: system-run-cart -title: Validate Games Root Launch End To End -status: done -created: 2026-07-03 -ref_decisions: [DEC-0036] -tags: [] ---- - -## Briefing - -`PLN-0132` through `PLN-0134` specify, discover, display, and launch games -from a local games root. This plan closes the slice by adding end-to-end -validation evidence and manual smoke instructions for the product behavior the -user wants: point the SO at a directory, click a listed game, and boot it. - -## Objective - -Prove the complete `--games-root -> Home list -> click -> LoadCartridge -> -GameRunning` path while preserving direct `--run` boot and the absence of -userland `run_cart`. - -## Dependencies - -- Source decision: `DEC-0036`. -- `PLN-0132`, `PLN-0133`, and `PLN-0134` must be complete. -- Existing test cartridge fixtures, especially `test-cartridges/stress-console`. - -## Scope - -- Add automated integration coverage for the full games-root Home launch path - where the existing host/firmware test seams allow it. -- Add or document a minimal local games-root fixture using existing directory - cartridges. -- Validate invalid-candidate omission at the end-to-end boundary. -- Validate direct `--run` still works after games-root wiring. -- Capture manual smoke-test commands for release-mode functional verification. - -## Non-Goals - -- No new product UI polish. -- No screenshot automation unless the existing test harness already supports it - cleanly. -- No return-to-Home after a running Game. -- No Game A -> Home -> Game B orchestration. -- No `.pmc` validation. -- No broad performance tuning beyond confirming release-mode smoke behavior. - -## Execution Method - -1. Add an integration-style firmware or host test that constructs a games root - with a valid Game directory cartridge and boots to Hub/Home with that root. -2. Drive the Home selection path through the same action path used by user - input, not by calling the cartridge loader directly. -3. Assert that the machine reaches `LoadCartridge` or `GameRunning`, depending - on the most stable observable state available in the test harness. -4. Add a negative case with an invalid candidate and assert it is omitted or - reported as a diagnostic without appearing in Home. -5. Add a regression assertion that no public guest syscall named - `system.run_cart` is required by this path. -6. Re-run direct boot tests or add a small regression test proving - `--run test-cartridges/stress-console` remains independent from - `--games-root`. -7. Record manual smoke commands in the relevant spec or developer-facing doc: - - `cargo run --release -p prometeu-host-desktop-winit -- --games-root test-cartridges` - - `cargo run --release -p prometeu-host-desktop-winit -- --run test-cartridges/stress-console` - -## Acceptance Criteria - -- Automated tests cover a valid games-root Home launch path. -- Automated tests cover invalid candidate omission or diagnostics at the - end-to-end boundary. -- Automated or documented validation proves direct `--run` still works. -- Manual smoke instructions use release mode for realistic interactive speed. -- Validation evidence shows no guest syscall participates in launch. -- `discussion validate` passes. - -## Tests - -- Run `cargo test -p prometeu-system`. -- Run `cargo test -p prometeu-firmware`. -- Run `cargo test -p prometeu-host-desktop-winit`. -- Run `cargo test -p prometeu-hal syscalls` to keep the removed syscall surface - guarded. -- Manually smoke test: - `cargo run --release -p prometeu-host-desktop-winit -- --games-root test-cartridges`. -- Manually smoke test: - `cargo run --release -p prometeu-host-desktop-winit -- --run test-cartridges/stress-console`. -- Run `discussion validate`. - -## Affected Artifacts - -- `crates/console/prometeu-system/` -- `crates/console/prometeu-firmware/` -- `crates/host/prometeu-host-desktop-winit/` -- `crates/console/prometeu-hal/src/syscalls/tests.rs` -- Runtime specs or developer docs containing smoke commands -- `discussion/workflow/plans/PLN-0135-validate-games-root-launch-end-to-end.md` diff --git a/docs/specs/runtime/04-gfx-peripheral.md b/docs/specs/runtime/04-gfx-peripheral.md index 05680535..d6801caa 100644 --- a/docs/specs/runtime/04-gfx-peripheral.md +++ b/docs/specs/runtime/04-gfx-peripheral.md @@ -239,8 +239,10 @@ The same physical surface may be reused by multiple visual owners, but logical ownership MUST remain explicit. Game pause/resume, foreground stack behavior, and coexistence of a paused Game -with VM-backed Shell apps are outside this contract and are tracked by the -foreground/lifecycle discussion. +with VM-backed Shell apps are defined by the foreground/lifecycle contract. The +render boundary's responsibility is narrower: foreground owner changes MUST +advance ownership, stale submissions MUST be discarded, and visual return to a +resumed Game MUST wait for a valid submission from the current ownership epoch. ### 4.7 Render telemetry diff --git a/docs/specs/runtime/06-input-peripheral.md b/docs/specs/runtime/06-input-peripheral.md index 3706584e..b91b9dd5 100644 --- a/docs/specs/runtime/06-input-peripheral.md +++ b/docs/specs/runtime/06-input-peripheral.md @@ -107,3 +107,21 @@ Input access is VM-owned and should not be reported as host syscall consumption. All platforms must provide the mandatory input elements (`pad`, `touch`, `button`) to the runtime. Platform differences in physical device mapping are resolved outside VM semantics. + +## 9 Host/System Controls + +Host/System controls are not part of the guest-visible input surface. + +The Home/SystemOS request changes machine foreground authority. It must be +handled by the host, firmware, or SystemOS before input is exposed to the Game. +It must not be represented as `pad.start`, `pad.select`, a new pad field, a VM +intrinsic, or a userland syscall. + +For the desktop host, `Esc` is the primary keyboard mapping for the +Home/SystemOS request. The physical `Home` key may be supported as an alias. +Both mappings are host controls and must not mutate `InputSignals`. + +When a Game leaves or re-enters foreground, pending Game input must be cleared +or barriered so held or pressed input cannot leak across pause/resume +boundaries. This barrier applies to the Game-facing snapshot; it does not +change the meaning of host/system Home controls. diff --git a/docs/specs/runtime/09-events-and-concurrency.md b/docs/specs/runtime/09-events-and-concurrency.md index 497c25c0..4ec0e44a 100644 --- a/docs/specs/runtime/09-events-and-concurrency.md +++ b/docs/specs/runtime/09-events-and-concurrency.md @@ -100,7 +100,70 @@ Important properties: - no execution occurs outside the frame loop; - frame structure remains observable for host tooling and host-owned certification. -## 7 Render Worker Concurrency +## 7 Foreground Lifecycle Events + +Foreground lifecycle events are system events with bounded delivery points. +They do not execute arbitrary guest callbacks at arrival time. + +The Game pause path is: + +```text +Home/SystemOS request + -> pause event becomes visible to the Game + -> bounded pause reaction budget + -> SystemOS suspension, even if the Game did not cooperate +``` + +The Game resume path is: + +```text +SystemOS resume request + -> VM reactivation + -> resume or foreground-restore event becomes visible to the Game + -> Game may synchronize while still internally paused + -> visual foreground returns after a valid current-epoch render submission +``` + +`Paused` is the Game-visible event/state. `Suspended` is the OS-owned +scheduler/runtime state. A Game may observe pause/resume, but it must not +control whether SystemOS suspends or resumes the VM. + +Pause budgets are measured in deterministic machine units, not host wall-clock +time. A suspended Game receives no normal gameplay ticks, no normal Game input, +and no frame pacing. Real background execution is outside the v1 foreground +contract. + +## 8 VM Sessions and Execution Eligibility + +Every VM-backed process MUST own a distinct VM session. A VM session owns the +mutable execution context for that process, including the VM instance, +per-session runtime counters, lifecycle delivery bookkeeping, debug/pause state, +and transient handles opened through VM-facing services. + +Foreground ownership and execution eligibility are related but separate +concepts: + +- foreground ownership selects the visual/navigation owner; +- execution eligibility selects which sessions may receive VM ticks; +- v1 grants normal VM execution only to the foreground VM-backed task, plus the + bounded Game pause handoff tick defined above; +- a suspended resident Game preserves its VM session but MUST NOT receive + normal gameplay ticks, normal Game input, or frame pacing; +- a VM-backed Shell owns its own VM session and MUST NOT overwrite or advance + the resident Game session while the Shell is foreground; +- a native Shell does not own a VM session. + +The model is intentionally compatible with future background execution. Future +service or media processes may become execution-eligible without changing the +rule that mutable VM context belongs to the owning session. This chapter does +not grant any v1 background progress guarantee. + +Durable app data remains keyed by `app_id` where the relevant storage contract +requires it. Transient VM handles, open files, lifecycle delivery queues, and +staging state are session-scoped unless another spec explicitly makes them +durable app data. + +## 9 Render Worker Concurrency The render worker is not a machine-visible event source and does not introduce guest callbacks. It is an implementation-side consumer of closed render @@ -127,7 +190,7 @@ Shutdown is explicit and bounded. A shutdown request wakes a waiting worker, causes pending work that will not be consumed to be discarded, and reports a typed failure if the worker cannot join within the configured timeout. -## 8 Async Asset and IO Work Lane +## 10 Async Asset and IO Work Lane The asset/IO async work lane is not a machine-visible event source and does not introduce guest callbacks. It is an implementation-side lane for asset @@ -151,7 +214,7 @@ does not publish resident graphics/audio/scene state directly. FS and game persistence services may consume this lane for IO-style work, but public FS API shape is defined by the FS/app-home contract, not by this chapter. -## 9 Determinism and Best Practices +## 11 Determinism and Best Practices PROMETEU encourages: @@ -166,7 +229,7 @@ PROMETEU discourages: - hidden timing channels; - ambiguous out-of-band execution. -## 10 Relationship to Other Specs +## 12 Relationship to Other Specs - [`09a-coroutines-and-cooperative-scheduling.md`](09a-coroutines-and-cooperative-scheduling.md) defines coroutine lifecycle and scheduling behavior. - [`10-debug-inspection-and-profiling.md`](10-debug-inspection-and-profiling.md) defines observability and diagnostics surfaces. diff --git a/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md b/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md index 86a25bab..b75246c9 100644 --- a/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md +++ b/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md @@ -43,13 +43,23 @@ The VM does not own the machine lifecycle. Firmware does. Game directory cartridges. - **Library entry**: an internal catalog record retained by Hub/SystemOS for a discovered cartridge. +- **Foreground visual owner**: the single machine owner whose visual output may + be presented at a given time. +- **Paused**: a Game-visible lifecycle event/state. The Game may observe it and + prepare for foreground loss. +- **Suspended**: an OS-owned scheduler/runtime state. A suspended Game remains + resident but does not receive normal gameplay ticks, normal Game input, or + frame pacing. +- **VM session**: the POS-owned mutable execution context for one VM-backed + process. It contains the VM instance and per-session runtime state. ## 3 POS Responsibilities POS is responsible for: - deterministic reset into known machine state; -- VM initialization and teardown for cartridge execution; +- VM session creation, initialization, activation, and teardown for cartridge + execution; - input latching and logical-frame budgeting; - peripheral reset/orchestration; - fault capture and crash-flow transition; @@ -61,6 +71,10 @@ At the VM boundary, POS must preserve: - `FRAME_SYNC` as the canonical frame boundary; - deterministic transition from host tick to VM slice execution. +Firmware must not treat a single global VM object as the canonical execution +owner for all cartridges. The firmware state machine orchestrates macro-state; +POS/session services own VM-backed process context. + ## 4 PrometeuHub Responsibilities PrometeuHub is responsible for: @@ -154,19 +168,74 @@ Shell task loses its eligible focused window -> FirmwareState::HubHome ``` -## 7 Cartridge Load Flow +## 7 Foreground Stack and Game Pause + +The v1 foreground contract is single-owner: + +- exactly one foreground visual owner exists at a time; +- at most one Game may be resident; +- at most one Shell app may be foreground; +- Hub/Home is the root Shell owner and back-stack controller. + +The minimal supported navigation cycle is: + +```text +Game foreground + -> Home request + -> Game pause notification + -> Game suspended by SystemOS after a bounded budget + -> Hub/Home foreground + -> optional Shell foreground + -> Shell close returns to Hub/Home + -> same Game resume/foreground-restore notification + -> Game foreground after a valid current-epoch render submission +``` + +`Paused` is cooperative and visible to the Game. `Suspended` is imposed by +SystemOS. The Game may react to pause/resume, but it must not control whether +the OS suspends or resumes its VM. + +When Home is requested during Game execution, SystemOS must notify the Game and +grant a short bounded pause budget. After the budget, SystemOS may suspend the +VM even if the Game did not cooperate. While suspended, the Game must not +receive normal gameplay ticks, normal Game input, or frame pacing. + +When returning to the same Game, SystemOS must reactivate the VM and send a +resume or foreground-restore notification before visual foreground is restored. +The Game may remain internally paused while it synchronizes. Hub/Shell remains +visible until the resumed Game publishes a valid render submission for the +current ownership epoch. + +Input pending for the Game must be cleared or barriered across pause/resume +boundaries. Game audio pauses with the Game VM in v1. Independent background +audio, real background execution, multiple resident Games, multiple foreground +Shells, and direct Game-to-Game switching are outside this contract. + +The resident Game VM session remains allocated while Hub or Shell is foreground. +It preserves Game VM state but is not execution-eligible except for the bounded +pause handoff and later foreground resume. A VM-backed Shell has a separate VM +session. A native Shell has no VM session. + +## 8 Cartridge Load Flow Current high-level flow: 1. POS receives a cartridge to load; 2. asset manager is initialized from the cartridge asset table, preload list, and packed asset bytes; -3. POS initializes the VM/runtime for the cartridge; -4. launch behavior branches by `AppMode`: +3. POS creates or selects the VM session for the cartridge; +4. POS initializes the session-owned VM/runtime for the cartridge; +5. launch behavior branches by `AppMode`: - `Game` -> transition to the game-running firmware state; - `System` -> create/focus a Hub window and return to the Hub state. If VM initialization fails, firmware transitions to the crash path. +For v1, POS must enforce at most one resident Game session. Selecting the same +resident Game from Home resumes that session. Selecting a different Game while a +resident Game exists is outside the v1 foreground contract and must not create a +second resident Game session. VM-backed Shell loading creates a distinct Shell +session and must not overwrite resident Game VM state. + POS selects the cartridge and its execution context, but the cartridge's initial callable is not chosen by firmware metadata. Execution starts from the cartridge boot protocol defined in [`13-cartridge.md`](13-cartridge.md), currently `func_id = 0`. When Home launches a discovered Game entry, the request is a system-owned action @@ -179,7 +248,7 @@ If the selected cartridge cannot be loaded, firmware must leave or return the machine in the Hub/Home flow and record the failure through diagnostics or logging. A polished Home error surface is not required by v1. -## 8 Firmware States +## 9 Firmware States The current firmware state model includes: @@ -189,11 +258,12 @@ The current firmware state model includes: - `HubHome` - `LoadCartridge` - `GameRunning` +- `ShellRunning` - `AppCrashes` These states express machine orchestration above the VM. They are not guest-visible bytecode states. -## 9 Execution Contract +## 10 Execution Contract For game-mode execution, firmware/runtime coordination preserves: @@ -204,7 +274,7 @@ For game-mode execution, firmware/runtime coordination preserves: This keeps the machine model deterministic and observable. -## 10 Crash Handling +## 11 Crash Handling Firmware owns terminal fault presentation. @@ -216,7 +286,32 @@ When a terminal app fault occurs: Crash handling is outside the guest VM execution model. -## 11 Relationship to Other Specs +## 12 Desktop Smoke Validation + +The foreground contract can be smoke-tested on the desktop host with: + +```text +cargo run --release -p prometeu-host-desktop-winit -- --run test-cartridges/stress-console +cargo run --release -p prometeu-host-desktop-winit -- --games-root test-cartridges +``` + +Expected behavior: + +- direct `--run` still boots the selected Game without using a guest cartridge + switching API; +- `--games-root` starts in Home and launches Games through the Home library; +- while a Game is running, `Esc` opens Home/SystemOS without entering the guest + pad input surface; +- the Game is paused, then suspended after the bounded SystemOS budget; +- Home remains foreground while the Game is resident; +- launching and closing a Shell app returns to Home without losing the resident + Game; +- a VM-backed Shell advances its own VM session and does not tick the suspended + resident Game session; +- returning to the same Game resumes it through the foreground lifecycle and + waits for current-epoch Game rendering before visual Game presentation. + +## 13 Relationship to Other Specs - [`02-vm-instruction-set.md`](02-vm-instruction-set.md) defines the VM subsystem run by firmware. - [`09-events-and-concurrency.md`](09-events-and-concurrency.md) defines the frame-boundary model used by firmware. diff --git a/docs/specs/runtime/14-boot-profiles.md b/docs/specs/runtime/14-boot-profiles.md index ec9611c6..a420804c 100644 --- a/docs/specs/runtime/14-boot-profiles.md +++ b/docs/specs/runtime/14-boot-profiles.md @@ -50,6 +50,8 @@ For a cartridge boot target: - `Game` cartridges transition into the game-running pipeline; - `System` cartridges transition into a Runtime/Hub pipeline dedicated to system UI and app hosting. +- VM-backed cartridges are initialized in a POS-owned VM session associated with + the created task/process. This preserves the distinction between machine firmware state and app execution mode. @@ -99,6 +101,14 @@ Game-to-game switching after a game is already active is not part of this boot profile. That orchestration belongs to the foreground/home and cartridge switch contracts. +The foreground/home contract defines `Game -> Home/Shell -> same Game` after a +Game is already running. It does not change direct `--run`, debugger direct +boot, or `--games-root` Home library startup semantics. Home/SystemOS requests +are host/system controls, not guest cartridge boot APIs. + +Direct boot creates the initial VM session for the selected cartridge. It does +not create a reusable global VM shared by later Game or Shell cartridges. + ## 5 Firmware State Relationship Boot target selection feeds into firmware states such as: diff --git a/docs/specs/runtime/16-host-abi-and-syscalls.md b/docs/specs/runtime/16-host-abi-and-syscalls.md index c759a232..88a4749a 100644 --- a/docs/specs/runtime/16-host-abi-and-syscalls.md +++ b/docs/specs/runtime/16-host-abi-and-syscalls.md @@ -187,6 +187,11 @@ The game memcard profile uses module `mem` with status-first return shapes. The `mem` module is a game profile surface. It is not the public storage model for `System` profile apps. +Durable memcard data is keyed by `app_id` according to the save-memory +contract. Runtime handles, open file tables, pending lifecycle delivery, and +other mutable VM-facing syscall context are scoped to the owning VM session and +MUST NOT be used as durable app identity. + Canonical operations in v1 are: - `mem.slot_count() -> (status, count)`