diff --git a/Cargo.lock b/Cargo.lock index e3fbe577..5bcd2b9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,6 +1469,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbxgen-dummy-boy" +version = "0.1.0" +dependencies = [ + "anyhow", + "prometeu-bytecode", + "prometeu-hal", + "serde_json", +] + [[package]] name = "pbxgen-stress" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index cc8e5d61..278dd394 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/console/prometeu-vm", "crates/host/prometeu-host-desktop-winit", "crates/tools/prometeu-cli", + "crates/tools/pbxgen-dummy-boy", "crates/tools/pbxgen-stress", "crates/dev/prometeu-test-support", "crates/dev/prometeu-layer-tests", diff --git a/crates/console/prometeu-drivers/src/asset.rs b/crates/console/prometeu-drivers/src/asset.rs index b80ddff2..e846d8ec 100644 --- a/crates/console/prometeu-drivers/src/asset.rs +++ b/crates/console/prometeu-drivers/src/asset.rs @@ -1858,6 +1858,9 @@ impl AssetManager { self.glyph_slot_index.clear(); self.sound_slots.write().unwrap().fill(None); self.scene_slots.write().unwrap().fill(None); + self.gfx_installer.clear_glyph_banks(); + self.sound_installer.clear_sound_banks(); + self.scene_installer.clear_scene_banks(); } pub fn glyph_asset_slot_index(&self) -> GlyphAssetSlotIndex { @@ -2668,6 +2671,51 @@ mod tests { assert_eq!(am.slot_info(SlotRef::scene(4)).asset_id, Some(2)); } + #[test] + fn shutdown_clears_physical_memory_bank_slots() { + let banks = Arc::new(MemoryBanks::new()); + let gfx_installer = Arc::clone(&banks) as Arc; + let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; + + let glyph_data = test_glyph_asset_data(); + let scene = test_scene(); + let scene_data = encode_scene_payload(&scene); + let glyph_entry = test_glyph_asset_entry("preload_glyphs", 16, 16); + let scene_entry = test_scene_asset_entry("preload_scene", &scene_data, &scene); + + let mut payload = Vec::new(); + payload.extend_from_slice(&glyph_data); + payload.extend_from_slice(&scene_data); + let mut shifted_scene_entry = scene_entry; + shifted_scene_entry.offset = glyph_data.len() as u64; + + let am = AssetManager::new( + vec![], + AssetsPayloadSource::empty(), + gfx_installer, + sound_installer, + scene_installer, + ); + am.initialize_for_cartridge( + vec![glyph_entry, shifted_scene_entry], + vec![PreloadEntry { asset_id: 0, slot: 0 }, PreloadEntry { asset_id: 2, slot: 0 }], + AssetsPayloadSource::from_bytes(payload), + ); + + assert!(banks.glyph_bank_slot(0).is_some()); + assert!(banks.scene_bank_slot(0).is_some()); + assert_eq!(am.slot_info(SlotRef::gfx(0)).asset_id, Some(0)); + assert_eq!(am.slot_info(SlotRef::scene(0)).asset_id, Some(2)); + + am.shutdown(); + + assert!(banks.glyph_bank_slot(0).is_none()); + assert!(banks.scene_bank_slot(0).is_none()); + assert_eq!(am.slot_info(SlotRef::gfx(0)).asset_id, None); + assert_eq!(am.slot_info(SlotRef::scene(0)).asset_id, None); + } + #[test] fn test_load_returns_asset_not_found() { let banks = Arc::new(MemoryBanks::new()); diff --git a/crates/console/prometeu-drivers/src/memory_banks.rs b/crates/console/prometeu-drivers/src/memory_banks.rs index a245a512..1eef36e6 100644 --- a/crates/console/prometeu-drivers/src/memory_banks.rs +++ b/crates/console/prometeu-drivers/src/memory_banks.rs @@ -24,6 +24,8 @@ pub trait GlyphBankPoolAccess: Send + Sync { pub trait GlyphBankPoolInstaller: Send + Sync { /// Atomically swaps the resident GlyphBank in the specified slot. fn install_glyph_bank(&self, slot: usize, bank: Arc); + /// Clears all resident GlyphBank slots. + fn clear_glyph_banks(&self); } /// Non-generic interface for peripherals to access sound banks. @@ -38,6 +40,8 @@ pub trait SoundBankPoolAccess: Send + Sync { pub trait SoundBankPoolInstaller: Send + Sync { /// Atomically swaps the resident SoundBank in the specified slot. fn install_sound_bank(&self, slot: usize, bank: Arc); + /// Clears all resident SoundBank slots. + fn clear_sound_banks(&self); } /// Non-generic interface for peripherals to access scene banks. @@ -52,6 +56,8 @@ pub trait SceneBankPoolAccess: Send + Sync { pub trait SceneBankPoolInstaller: Send + Sync { /// Atomically swaps the resident SceneBank in the specified slot. fn install_scene_bank(&self, slot: usize, bank: Arc); + /// Clears all resident SceneBank slots. + fn clear_scene_banks(&self); } /// Centralized container for all hardware memory banks. @@ -100,6 +106,10 @@ impl GlyphBankPoolInstaller for MemoryBanks { pool[slot] = Some(bank); } } + + fn clear_glyph_banks(&self) { + self.glyph_bank_pool.write().unwrap().fill(None); + } } impl SoundBankPoolAccess for MemoryBanks { @@ -120,6 +130,10 @@ impl SoundBankPoolInstaller for MemoryBanks { pool[slot] = Some(bank); } } + + fn clear_sound_banks(&self) { + self.sound_bank_pool.write().unwrap().fill(None); + } } impl SceneBankPoolAccess for MemoryBanks { @@ -140,6 +154,10 @@ impl SceneBankPoolInstaller for MemoryBanks { pool[slot] = Some(bank); } } + + fn clear_scene_banks(&self) { + self.scene_bank_pool.write().unwrap().fill(None); + } } impl HalRenderResourceAccess for MemoryBanks { diff --git a/crates/console/prometeu-firmware/src/firmware/firmware.rs b/crates/console/prometeu-firmware/src/firmware/firmware.rs index 153fb92f..37ddb1fa 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware.rs @@ -250,21 +250,24 @@ impl Firmware { mod tests { use super::*; use crate::firmware::firmware_state::HubHomeStep; + use crate::firmware::resident_game_termination::{self, ResidentGameTerminationOutcome}; use prometeu_bytecode::assembler::assemble; use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl}; - use prometeu_drivers::TestPlatform; + use prometeu_drivers::{GlyphBankPoolAccess, SceneBankPoolAccess, TestPlatform}; use prometeu_hal::RuntimePlatform; use prometeu_hal::app_mode::AppMode; + use prometeu_hal::asset::{BankType, SlotRef}; use prometeu_hal::cartridge::AssetsPayloadSource; use prometeu_hal::color::Color; + use prometeu_hal::log::{LogLevel, LogSource}; use prometeu_hal::primitives::Rect; use prometeu_hal::syscalls::{Syscall, caps}; use prometeu_system::process::ProcessState; use prometeu_system::task::{TaskId, TaskState}; use prometeu_system::windows::WindowOwner; use prometeu_system::{ - CrashReport, ForegroundOwner, GameLifecycleEventKind, ResidentGameState, - discover_games_root, + CrashReport, ForegroundOwner, GameLifecycleEventKind, HUB_SUSPENDED_GAME_KILL_CONTROL, + ResidentGameState, discover_games_root, }; use std::fs; use std::path::PathBuf; @@ -289,20 +292,45 @@ mod tests { } fn add_game(&self, name: &str, with_program: bool) -> PathBuf { + self.add_game_with_identity(name, 42, "Stress", "1.0.0", with_program) + } + + fn add_game_with_identity( + &self, + name: &str, + app_id: u32, + title: &str, + app_version: &str, + with_program: bool, + ) -> PathBuf { + let program = with_program.then(halting_program); + self.add_game_with_program(name, app_id, title, app_version, program) + } + + fn add_game_with_program( + &self, + name: &str, + app_id: u32, + title: &str, + app_version: &str, + program: Option>, + ) -> PathBuf { let cart = self.path.join(name); fs::create_dir_all(&cart).expect("test cartridge should be created"); - let manifest = r#"{ - "magic": "PMTU", - "cartridge_version": 1, - "app_id": 42, - "title": "Stress", - "app_version": "1.0.0", - "app_mode": "Game" - }"#; + let manifest = format!( + r#"{{ + "magic": "PMTU", + "cartridge_version": 1, + "app_id": {app_id}, + "title": "{title}", + "app_version": "{app_version}", + "app_mode": "Game" + }}"# + ); fs::write(cart.join("manifest.json"), manifest) .expect("test manifest should be written"); - if with_program { - fs::write(cart.join("program.pbx"), halting_program()) + if let Some(program) = program { + fs::write(cart.join("program.pbx"), program) .expect("test program should be written"); } cart @@ -333,6 +361,10 @@ mod tests { .serialize() } + fn repository_test_cartridges_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").join("test-cartridges") + } + fn invalid_game_cartridge() -> Cartridge { Cartridge { app_id: 7, @@ -447,6 +479,12 @@ mod tests { windows.set_focus(id); } + fn front_buffer_pixel(platform: &TestPlatform, x: i32, y: i32) -> u32 { + const FRAMEBUFFER_WIDTH: usize = 480; + let index = x as usize + y as usize * FRAMEBUFFER_WIDTH; + platform.local_hardware().gfx.front_buffer()[index] + } + #[test] fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() { let mut firmware = Firmware::new(None); @@ -728,6 +766,499 @@ mod tests { ); } + #[test] + fn hub_home_clicking_different_game_terminates_previous_resident_session() { + let root = TestGameRoot::new(); + root.add_game("stress", true); + root.add_game_with_identity("zzz-dummy", 43, "Zzz Dummy", "1.0.0", 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 stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let first_game_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected first GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(first_game_task)); + + firmware.tick(&InputSignals::default(), &mut platform); + let dummy_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&dummy_click, &mut platform); + + assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(first_game_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware + .os + .sessions() + .vm_session_for_task(first_game_task) + .map(|session| session.task_id), + None + ); + + let second_game_task = firmware + .os + .lifecycle() + .resident_game_task() + .expect("replacement game should be resident"); + assert_ne!(second_game_task, first_game_task); + assert_eq!( + firmware + .os + .sessions() + .vm_session_for_task(second_game_task) + .map(|session| session.app_id), + Some(43) + ); + + firmware.tick(&InputSignals::default(), &mut platform); + + match &firmware.state { + FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_game_task), + other => panic!("expected replacement GameRunning state, got {:?}", other), + } + } + + #[test] + fn repository_stress_to_dummy_boy_switch_is_end_to_end() { + let library = discover_games_root(repository_test_cartridges_root()); + assert!( + library + .entries() + .iter() + .any(|entry| entry.app_id == 1 && entry.title == "Stress Console") + ); + assert!( + library.entries().iter().any(|entry| entry.app_id == 2 && entry.title == "Dummy Boy") + ); + + let (mut firmware, mut platform) = hub_home_firmware(); + firmware.hub.set_game_library(library); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected Stress GameRunning state, got {:?}", other), + }; + assert_eq!(firmware.os.vm().current_app_id(), 1); + assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console"); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + Some(100) + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + Some(1) + ); + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + let hub_ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(hub_ownership.app_mode, AppMode::Shell); + assert_eq!(hub_ownership.app_id, 0); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + Some(100) + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + Some(1) + ); + + firmware.tick(&InputSignals::default(), &mut platform); + let dummy_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() }; + firmware.tick(&dummy_click, &mut platform); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + None + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + None + ); + assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none()); + assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none()); + + assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(stress_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id), + None + ); + + let dummy_task = firmware + .os + .lifecycle() + .resident_game_task() + .expect("Dummy Boy should be resident after switch load"); + assert_ne!(dummy_task, stress_task); + assert_eq!( + firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| { + (session.app_id, session.title.clone(), session.app_version.clone()) + }), + Some((2, "Dummy Boy".to_string(), "0.1.0".to_string())) + ); + + firmware.tick(&InputSignals::default(), &mut platform); + + match &firmware.state { + FirmwareState::GameRunning(step) => assert_eq!(step.task_id, dummy_task), + other => panic!("expected Dummy Boy GameRunning state, got {:?}", other), + } + assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(dummy_task)); + assert_eq!(firmware.os.vm().current_app_id(), 2); + assert_eq!(firmware.os.vm().current_cartridge_title(), "Dummy Boy"); + + firmware.tick(&InputSignals::default(), &mut platform); + + let dummy_ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(dummy_ownership.app_mode, AppMode::Game); + assert_eq!(dummy_ownership.app_id, 2); + + let front = platform.local_hardware().gfx.front_buffer(); + let center = 240 + 135 * 480; + assert_eq!(front[center], Color::GREEN.raw()); + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + let hub_ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(hub_ownership.app_mode, AppMode::Shell); + assert_eq!(hub_ownership.app_id, 0); + + firmware.tick(&InputSignals::default(), &mut platform); + let stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + + assert_eq!(firmware.os.lifecycle().task_state(dummy_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(dummy_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| session.task_id), + None + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + Some(100) + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + Some(1) + ); + assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some()); + assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some()); + + let second_stress_task = firmware + .os + .lifecycle() + .resident_game_task() + .expect("Stress should be resident after switching back"); + assert_ne!(second_stress_task, stress_task); + assert_ne!(second_stress_task, dummy_task); + + firmware.tick(&InputSignals::default(), &mut platform); + + match &firmware.state { + FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_stress_task), + other => panic!("expected second Stress GameRunning state, got {:?}", other), + } + assert_eq!(firmware.os.vm().current_app_id(), 1); + assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console"); + + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(stress_ownership.app_mode, AppMode::Game); + assert_eq!(stress_ownership.app_id, 1); + } + + #[test] + fn resident_game_termination_noops_when_no_game_is_resident() { + let (mut firmware, mut platform) = hub_home_firmware(); + let ownership_before = firmware.os.vm().active_render_ownership(); + + let outcome = resident_game_termination::terminate_resident_game( + &mut firmware.os, + &mut platform, + true, + ) + .expect("no resident game termination should not fail"); + + assert_eq!(outcome, ResidentGameTerminationOutcome::NoResidentGame); + assert_eq!(firmware.os.vm().active_render_ownership(), ownership_before); + assert_eq!(firmware.os.lifecycle().resident_game_task(), None); + assert!( + !firmware + .os + .recent_logs(16) + .iter() + .any(|event| event.msg.contains("Resident game killed from Hub")) + ); + } + + #[test] + fn resident_game_termination_closes_session_and_cleans_runtime_state() { + let library = discover_games_root(repository_test_cartridges_root()); + let (mut firmware, mut platform) = hub_home_firmware(); + firmware.hub.set_game_library(library); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected Stress 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(stress_task)); + assert!(firmware.game_lifecycle_audio_paused()); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + Some(100) + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + Some(1) + ); + assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some()); + assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some()); + + let outcome = resident_game_termination::terminate_resident_game( + &mut firmware.os, + &mut platform, + true, + ) + .expect("resident game termination should succeed"); + + assert_eq!( + outcome, + ResidentGameTerminationOutcome::Terminated { + task_id: stress_task, + title: "Stress Console".to_string() + } + ); + assert_eq!(firmware.os.lifecycle().resident_game_task(), None); + assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(stress_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id), + None + ); + assert!(!firmware.game_lifecycle_audio_paused()); + + let ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(ownership.app_mode, AppMode::Shell); + assert_eq!(ownership.app_id, 0); + assert_eq!(platform.local_hardware().frame_composer.active_scene_id(), None); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + None + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + None + ); + assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none()); + assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none()); + + assert!( + firmware.os.recent_logs(32).iter().any(|event| { + event.level == LogLevel::Info + && event.source == LogSource::Hub + && event.msg == "Resident game killed from Hub: Stress Console" + }), + "manual Hub kill should emit an INFO log" + ); + } + + #[test] + fn hub_kill_control_click_terminates_resident_game_and_stays_home() { + let library = discover_games_root(repository_test_cartridges_root()); + let (mut firmware, mut platform) = hub_home_firmware(); + firmware.hub.set_game_library(library); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected Stress GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(stress_task)); + + let kill_sample_x = HUB_SUSPENDED_GAME_KILL_CONTROL.x + 2; + let kill_sample_y = HUB_SUSPENDED_GAME_KILL_CONTROL.y + 2; + assert_eq!( + front_buffer_pixel(&platform, kill_sample_x, kill_sample_y), + Color::rgb(164, 32, 40).raw(), + "Hub should render the suspended-game kill control before click" + ); + + let kill_click = InputSignals { + f_signal: true, + x_pos: HUB_SUSPENDED_GAME_KILL_CONTROL.x, + y_pos: HUB_SUSPENDED_GAME_KILL_CONTROL.y, + ..Default::default() + }; + firmware.tick(&kill_click, &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(), None); + assert_eq!(firmware.os.lifecycle().resident_game(), None); + assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(stress_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id), + None + ); + assert!(!firmware.game_lifecycle_audio_paused()); + assert!(!firmware.resume_resident_game_from_home()); + + let ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(ownership.app_mode, AppMode::Shell); + assert_eq!(ownership.app_id, 0); + assert_eq!(platform.local_hardware().frame_composer.active_scene_id(), None); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + None + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + None + ); + assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none()); + assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none()); + assert!( + firmware.os.recent_logs(32).iter().any(|event| { + event.level == LogLevel::Info + && event.source == LogSource::Hub + && event.msg == "Resident game killed from Hub: Stress Console" + }), + "Hub kill control should use the canonical manual kill log" + ); + + firmware.tick(&InputSignals::default(), &mut platform); + assert_eq!( + front_buffer_pixel(&platform, kill_sample_x, kill_sample_y), + Color::rgb(12, 14, 22).raw(), + "Hub should stop rendering the kill control after the game is gone" + ); + } + + #[test] + fn failed_game_switch_returns_home_without_restoring_previous_game() { + let root = TestGameRoot::new(); + root.add_game("stress", true); + root.add_game_with_program("zzz-broken", 43, "Zzz Broken", "1.0.0", Some(vec![0, 0, 0, 0])); + + 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 stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let first_game_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected first GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let broken_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&broken_click, &mut platform); + + assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed)); + assert_eq!( + firmware + .os + .sessions() + .vm_session_for_task(first_game_task) + .map(|session| session.task_id), + None + ); + let partial_task = firmware + .os + .lifecycle() + .resident_game_task() + .expect("failed target should be resident until recovery tick"); + assert_ne!(partial_task, first_game_task); + + firmware.tick(&InputSignals::default(), &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().resident_game_task(), None); + assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed)); + assert_eq!(firmware.os.lifecycle().task_state(partial_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(partial_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware.os.sessions().vm_session_for_task(partial_task).map(|session| session.task_id), + None + ); + assert!( + firmware + .os + .recent_logs(16) + .iter() + .any(|event| event.msg.contains("Failed to switch to game Zzz Broken app_id 43")) + ); + } + #[test] fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() { let root = TestGameRoot::new(); @@ -910,6 +1441,8 @@ mod tests { }; let tick_index_before_home = firmware.os.vm().tick_index(); let render_ownership_before_home = firmware.os.vm().active_render_ownership(); + assert_eq!(render_ownership_before_home.app_mode, AppMode::Game); + assert_eq!(render_ownership_before_home.app_id, 9); firmware.request_home_from_host(); @@ -941,7 +1474,6 @@ mod tests { 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), @@ -970,6 +1502,8 @@ mod tests { firmware.request_home_from_host(); firmware.tick(&signals, &mut platform); let hub_ownership = firmware.os.vm().active_render_ownership(); + assert_eq!(hub_ownership.app_mode, AppMode::Shell); + assert_eq!(hub_ownership.app_id, 0); assert!(firmware.resume_resident_game_from_home()); assert!(matches!(firmware.state, FirmwareState::GameRunning(_))); @@ -982,7 +1516,6 @@ mod tests { 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 }; 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 f00f3966..3d852aa3 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 crate::firmware::resident_game_termination; use prometeu_hal::app_mode::AppMode; use prometeu_hal::cartridge_loader::CartridgeLoader; use prometeu_hal::log::{LogLevel, LogSource}; @@ -52,45 +53,71 @@ impl HubHomeStep { ctx.os.windows().set_focus(id); return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id))); } - 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); + Some(SystemProfileAction::LaunchGame { target }) => { + match CartridgeLoader::load(&target.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); + if resident_app_id == cartridge.app_id { + return Some(FirmwareState::ResumeResidentGame); + } + + if let Err(error) = resident_game_termination::terminate_resident_game( + ctx.os, + ctx.platform, + false, + ) { + ctx.os.log( + LogLevel::Error, + LogSource::Hub, + 0, + format!( + "Failed to terminate resident game app_id {} before launching {}: {:?}", + resident_app_id, cartridge.title, error + ), + ); + return None; + } } + return Some(FirmwareState::LoadCartridge( + LoadCartridgeStep::new_game_switch(cartridge), + )); + } + Err(error) => { ctx.os.log( - LogLevel::Warn, + LogLevel::Error, LogSource::Hub, 0, format!( - "Cannot launch Home game {} while resident game app_id {} is active", - cartridge.title, resident_app_id + "Failed to launch Home game {}: {:?}", + target.path.display(), + error ), ); - return None; } - - return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge))); } - Err(error) => { + } + Some(SystemProfileAction::KillResidentGame) => { + if let Err(error) = + resident_game_termination::terminate_resident_game(ctx.os, ctx.platform, true) + { ctx.os.log( LogLevel::Error, LogSource::Hub, 0, - format!("Failed to launch Home game {}: {:?}", path.display(), error), + format!("Failed to kill resident game from Hub: {error:?}"), ); } - }, + } Some(SystemProfileAction::CloseShell) | None => {} } 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 80307186..ccc2ed1d 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 @@ -1,7 +1,8 @@ use crate::firmware::firmware_state::{ - AppCrashesStep, FirmwareState, GameRunningStep, ShellRunningStep, + AppCrashesStep, FirmwareState, GameRunningStep, HubHomeStep, ShellRunningStep, }; use crate::firmware::prometeu_context::PrometeuContext; +use crate::firmware::resident_game_termination; use prometeu_hal::app_mode::AppMode; use prometeu_hal::cartridge::Cartridge; use prometeu_hal::color::Color; @@ -16,11 +17,28 @@ pub struct LoadCartridgeStep { init_error: Option, loaded_task_id: Option, loaded_app_mode: Option, + failure_recovery: LoadCartridgeFailureRecovery, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoadCartridgeFailureRecovery { + AppCrash, + ReturnToHub, } impl LoadCartridgeStep { pub fn new(cartridge: Cartridge) -> Self { - Self { cartridge, init_error: None, loaded_task_id: None, loaded_app_mode: None } + Self { + cartridge, + init_error: None, + loaded_task_id: None, + loaded_app_mode: None, + failure_recovery: LoadCartridgeFailureRecovery::AppCrash, + } + } + + pub fn new_game_switch(cartridge: Cartridge) -> Self { + Self { failure_recovery: LoadCartridgeFailureRecovery::ReturnToHub, ..Self::new(cartridge) } } pub fn on_enter(&mut self, ctx: &mut PrometeuContext) { @@ -31,6 +49,8 @@ impl LoadCartridgeStep { format!("Loading cartridge: {}", self.cartridge.title), ); + ctx.platform.game2d_frame_composer().unbind_scene(); + // Initialize Asset Manager ctx.platform.assets_mut().initialize_for_cartridge( self.cartridge.asset_table.clone(), @@ -51,6 +71,38 @@ impl LoadCartridgeStep { pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option { if let Some(report) = self.init_error.take() { + if self.failure_recovery == LoadCartridgeFailureRecovery::ReturnToHub { + if self.cartridge.app_mode == AppMode::Game + && let Some(task_id) = ctx.os.lifecycle().resident_game_task() + { + let is_partial_target = ctx + .os + .sessions() + .vm_session_for_task(task_id) + .map(|session| session.app_id == self.cartridge.app_id) + .unwrap_or(false); + + if is_partial_target { + let _ = resident_game_termination::terminate_resident_game( + ctx.os, + ctx.platform, + false, + ); + } + } + + ctx.os.log( + LogLevel::Error, + LogSource::Hub, + 0, + format!( + "Failed to switch to game {} app_id {}; returning to Home: {:?}", + self.cartridge.title, self.cartridge.app_id, report + ), + ); + return Some(FirmwareState::HubHome(HubHomeStep)); + } + return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); } diff --git a/crates/console/prometeu-firmware/src/firmware/mod.rs b/crates/console/prometeu-firmware/src/firmware/mod.rs index 0bc6518f..731258fc 100644 --- a/crates/console/prometeu-firmware/src/firmware/mod.rs +++ b/crates/console/prometeu-firmware/src/firmware/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod firmware_step_reset; pub(crate) mod firmware_step_shell_running; pub(crate) mod firmware_step_splash_screen; mod prometeu_context; +pub(crate) mod resident_game_termination; pub use boot_target::BootTarget; pub use firmware::Firmware; diff --git a/crates/console/prometeu-firmware/src/firmware/resident_game_termination.rs b/crates/console/prometeu-firmware/src/firmware/resident_game_termination.rs new file mode 100644 index 00000000..2df2f00e --- /dev/null +++ b/crates/console/prometeu-firmware/src/firmware/resident_game_termination.rs @@ -0,0 +1,42 @@ +use prometeu_hal::RuntimePlatform; +use prometeu_hal::app_mode::AppMode; +use prometeu_hal::log::LogSource; +use prometeu_system::task::TaskId; +use prometeu_system::{LifecycleError, SystemOS}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ResidentGameTerminationOutcome { + NoResidentGame, + Terminated { task_id: TaskId, title: String }, +} + +pub(crate) fn terminate_resident_game( + os: &mut SystemOS, + platform: &mut dyn RuntimePlatform, + log_manual_hub_kill: bool, +) -> Result { + let Some(task_id) = os.lifecycle().resident_game_task() else { + return Ok(ResidentGameTerminationOutcome::NoResidentGame); + }; + + let title = os + .sessions() + .vm_session_for_task(task_id) + .map(|session| session.title.clone()) + .unwrap_or_else(|| "".to_string()); + + let terminated = os.lifecycle().terminate_resident_game()?; + let Some(task_id) = terminated else { + return Ok(ResidentGameTerminationOutcome::NoResidentGame); + }; + + os.vm().transition_render_owner(AppMode::Shell, 0); + platform.game2d_frame_composer().unbind_scene(); + platform.assets_mut().shutdown(); + + if log_manual_hub_kill { + os.info(LogSource::Hub, format!("Resident game killed from Hub: {title}")); + } + + Ok(ResidentGameTerminationOutcome::Terminated { task_id, title }) +} diff --git a/crates/console/prometeu-system/src/lib.rs b/crates/console/prometeu-system/src/lib.rs index 20be73b7..f06c5ada 100644 --- a/crates/console/prometeu-system/src/lib.rs +++ b/crates/console/prometeu-system/src/lib.rs @@ -8,7 +8,10 @@ pub use os::{ DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError, LifecycleOperation, SessionError, SystemOS, }; -pub use programs::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate}; +pub use programs::{ + HUB_SUSPENDED_GAME_KILL_CONTROL, NativeShellApp, PrometeuHub, SystemProfileAction, + SystemProfileUpdate, +}; pub use prometeu_hal::{RenderWorkerBackend, RenderWorkerFrameSink}; pub use services::async_work::{ AsyncWorkActiveJob, AsyncWorkCancelToken, AsyncWorkJobContext, AsyncWorkJobId, @@ -20,7 +23,7 @@ pub use services::foreground::{ }; pub use services::fs; pub use services::game_library::{ - GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root, + GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, GameSwitchTarget, discover_games_root, }; pub use services::memcard::MemcardAsyncLaneOperation; pub use services::process; diff --git a/crates/console/prometeu-system/src/os/facades/lifecycle.rs b/crates/console/prometeu-system/src/os/facades/lifecycle.rs index 680a1cc5..b7f6d06a 100644 --- a/crates/console/prometeu-system/src/os/facades/lifecycle.rs +++ b/crates/console/prometeu-system/src/os/facades/lifecycle.rs @@ -222,6 +222,28 @@ impl<'a> LifecycleFacade<'a> { Ok(()) } + pub fn terminate_resident_game(&mut self) -> Result, LifecycleError> { + let Some(task_id) = self.os.foreground_stack.resident_game_task() else { + return Ok(None); + }; + + let process_id = process_id_for_task(self.os, task_id)?; + + self.os.task_manager.close_task(task_id); + self.os.process_manager.mark_stopped(process_id); + self.os.foreground_stack.clear_resident_game(task_id); + self.os.vm_sessions.remove_for_task(task_id); + self.os.game_lifecycle_events.retain(|event| event.task_id != task_id); + + if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Game(owner) if owner == task_id) + { + self.os.foreground_stack.return_to_hub(); + self.os.task_manager.clear_foreground(); + } + + Ok(Some(task_id)) + } + pub fn crash_task( &mut self, task_id: TaskId, diff --git a/crates/console/prometeu-system/src/os/facades/vm.rs b/crates/console/prometeu-system/src/os/facades/vm.rs index 367ac0f5..a76b167e 100644 --- a/crates/console/prometeu-system/src/os/facades/vm.rs +++ b/crates/console/prometeu-system/src/os/facades/vm.rs @@ -250,6 +250,12 @@ impl<'a> VmFacade<'a> { } pub fn active_render_ownership(&self) -> RenderOwnership { + if let Some(task_id) = self.foreground_session_task_id() + && let Some(session) = self.os.vm_sessions.for_task(task_id) + { + return session.runtime.render_manager.active_ownership(); + } + self.os.vm_runtime.render_manager.active_ownership() } @@ -317,4 +323,8 @@ impl<'a> VmFacade<'a> { .foreground_task() .or_else(|| self.os.foreground_stack.resident_game_task()) } + + fn foreground_session_task_id(&self) -> Option { + self.os.task_manager.foreground_task() + } } diff --git a/crates/console/prometeu-system/src/os/system_os.rs b/crates/console/prometeu-system/src/os/system_os.rs index a39cb2d3..23d6c048 100644 --- a/crates/console/prometeu-system/src/os/system_os.rs +++ b/crates/console/prometeu-system/src/os/system_os.rs @@ -218,6 +218,42 @@ mod tests { ); } + #[test] + fn terminate_resident_game_closes_task_and_removes_vm_session() { + 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"); + assert_eq!(os.lifecycle().pending_game_lifecycle_events().len(), 1); + + assert_eq!(os.lifecycle().terminate_resident_game(), Ok(Some(game))); + + assert_eq!(os.lifecycle().resident_game(), None); + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub); + assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed)); + assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped); + assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None); + assert!(os.lifecycle().pending_game_lifecycle_events().is_empty()); + } + + #[test] + fn terminate_resident_game_preserves_foreground_shell() { + 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().terminate_resident_game(), Ok(Some(game))); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell)); + assert_eq!(os.lifecycle().resident_game(), None); + assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed)); + assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped); + assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None); + assert_eq!(os.lifecycle().task_state(shell), Some(TaskState::Foreground)); + } + #[test] fn vm_game_task_creates_vm_session() { let mut os = SystemOS::new(None); diff --git a/crates/console/prometeu-system/src/programs/mod.rs b/crates/console/prometeu-system/src/programs/mod.rs index 916c4fb1..fcb844e1 100644 --- a/crates/console/prometeu-system/src/programs/mod.rs +++ b/crates/console/prometeu-system/src/programs/mod.rs @@ -1,3 +1,6 @@ mod prometeu_hub; -pub use prometeu_hub::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate}; +pub use prometeu_hub::{ + HUB_SUSPENDED_GAME_KILL_CONTROL, NativeShellApp, PrometeuHub, SystemProfileAction, + SystemProfileUpdate, +}; diff --git a/crates/console/prometeu-system/src/programs/prometeu_hub/mod.rs b/crates/console/prometeu-system/src/programs/prometeu_hub/mod.rs index 21d08e2d..fc06b2b8 100644 --- a/crates/console/prometeu-system/src/programs/prometeu_hub/mod.rs +++ b/crates/console/prometeu-system/src/programs/prometeu_hub/mod.rs @@ -1,4 +1,7 @@ #[allow(clippy::module_inception)] mod prometeu_hub; -pub use prometeu_hub::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate}; +pub use prometeu_hub::{ + HUB_SUSPENDED_GAME_KILL_CONTROL, NativeShellApp, PrometeuHub, SystemProfileAction, + SystemProfileUpdate, +}; 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 28038739..f9eeee34 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,12 +1,12 @@ use crate::task::TaskId; -use crate::{CrashReport, GameLibrary, SystemOS}; +use crate::{CrashReport, GameLibrary, GameSwitchTarget, SystemOS}; +use prometeu_hal::app_mode::AppMode; use prometeu_hal::color::Color; use prometeu_hal::primitives::Rect; use prometeu_hal::{ FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform, ShellUiFramePacket, }; -use std::path::PathBuf; const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 }; const SHELL_B_BUTTON: Rect = Rect { x: 256, y: 172, w: 112, h: 32 }; @@ -20,6 +20,7 @@ const GAME_ROW_W: i32 = 336; const GAME_ROW_H: i32 = 22; const GAME_ROW_GAP: i32 = 6; const MAX_HOME_GAME_ROWS: usize = 3; +pub const HUB_SUSPENDED_GAME_KILL_CONTROL: Rect = Rect { x: 288, y: 230, w: 168, h: 24 }; #[cfg(test)] const SHELL_FRAME: Rect = Rect { x: 40, y: 20, w: 400, h: 220 }; @@ -32,6 +33,8 @@ const COLOR_TEXT: Color = Color::rgb(230, 236, 224); const COLOR_MUTED: Color = Color::rgb(91, 112, 128); const COLOR_BUTTON: Color = Color::rgb(45, 59, 82); const COLOR_BUTTON_ACTIVE: Color = Color::rgb(72, 91, 122); +const COLOR_KILL: Color = Color::rgb(164, 32, 40); +const COLOR_KILL_ACTIVE: Color = Color::rgb(206, 46, 54); /// PrometeuHub: Launcher and system UI environment. pub struct PrometeuHub { @@ -83,7 +86,8 @@ impl NativeShellApp { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SystemProfileAction { LaunchNativeShell(NativeShellApp), - LaunchGame { path: PathBuf }, + LaunchGame { target: GameSwitchTarget }, + KillResidentGame, CloseShell, } @@ -132,12 +136,14 @@ impl PrometeuHub { } let input = platform.input(); + let suspended_game = if in_shell { None } else { resident_game_indicator(os) }; if let Some(action) = action_for_click( in_shell, input.touch().x(), input.touch().y(), input.touch().f().pressed, self.game_library.entries(), + suspended_game.as_ref(), ) { return Some(action); } @@ -150,7 +156,13 @@ impl PrometeuHub { let pointer_y = platform.input().touch().y(); let packet = if os.windows().window_count() == 0 { - render_home_packet(pointer_x, pointer_y, self.game_library.entries()) + let suspended_game = resident_game_indicator(os); + render_home_packet( + pointer_x, + pointer_y, + self.game_library.entries(), + suspended_game.as_ref(), + ) } else { let mut commands = Vec::new(); for window in os.windows().windows() { @@ -233,6 +245,7 @@ fn action_for_click( y: i32, pressed: bool, game_entries: &[crate::GameLibraryEntry], + suspended_game: Option<&SuspendedGameIndicator>, ) -> Option { if !pressed { return None; @@ -242,6 +255,10 @@ fn action_for_click( return rect_contains(CLOSE_BUTTON, x, y).then_some(SystemProfileAction::CloseShell); } + if suspended_game.is_some() && rect_contains(HUB_SUSPENDED_GAME_KILL_CONTROL, x, y) { + return Some(SystemProfileAction::KillResidentGame); + } + if let Some(entry) = game_entries .iter() .take(MAX_HOME_GAME_ROWS) @@ -249,7 +266,7 @@ fn action_for_click( .find(|(index, _entry)| rect_contains(game_row_rect(*index), x, y)) .map(|(_index, entry)| entry) { - return Some(SystemProfileAction::LaunchGame { path: entry.path.clone() }); + return Some(SystemProfileAction::LaunchGame { target: entry.switch_target() }); } HOME_BUTTONS @@ -258,6 +275,23 @@ fn action_for_click( .map(|button| SystemProfileAction::LaunchNativeShell(button.app)) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SuspendedGameIndicator { + title: String, +} + +fn resident_game_indicator(os: &mut SystemOS) -> Option { + let task_id = os.lifecycle().resident_game_task()?; + let sessions = os.sessions(); + let session = sessions.vm_session_for_task(task_id)?; + + if session.app_mode != AppMode::Game { + return None; + } + + Some(SuspendedGameIndicator { title: session.title.clone() }) +} + fn game_row_rect(index: usize) -> Rect { Rect { x: GAME_ROW_X, @@ -282,6 +316,7 @@ fn render_home_packet( pointer_x: i32, pointer_y: i32, game_entries: &[crate::GameLibraryEntry], + suspended_game: Option<&SuspendedGameIndicator>, ) -> ShellUiFramePacket { let mut commands = Vec::new(); commands.push(GfxUiCommand::Clear { color: COLOR_BG }); @@ -321,6 +356,11 @@ fn render_home_packet( draw_button(&mut commands, button.rect, button.label, hovered); } + if let Some(suspended_game) = suspended_game { + let hovered = rect_contains(HUB_SUSPENDED_GAME_KILL_CONTROL, pointer_x, pointer_y); + draw_suspended_game_kill_control(&mut commands, suspended_game, hovered); + } + ShellUiFramePacket::new(commands) } @@ -423,6 +463,39 @@ fn draw_game_row(commands: &mut Vec, rect: Rect, label: &str, hove draw_buffer_text(commands, rect.x + 8, rect.y + 7, label, COLOR_TEXT); } +fn draw_suspended_game_kill_control( + commands: &mut Vec, + suspended_game: &SuspendedGameIndicator, + hovered: bool, +) { + let fill = if hovered { COLOR_KILL_ACTIVE } else { COLOR_KILL }; + let border = if hovered { COLOR_HILITE } else { COLOR_BORDER }; + fill_rect(commands, HUB_SUSPENDED_GAME_KILL_CONTROL, fill); + draw_rect(commands, HUB_SUSPENDED_GAME_KILL_CONTROL, border); + draw_buffer_text( + commands, + HUB_SUSPENDED_GAME_KILL_CONTROL.x + 8, + HUB_SUSPENDED_GAME_KILL_CONTROL.y + 8, + &fit_text_to_control(&suspended_game.title, HUB_SUSPENDED_GAME_KILL_CONTROL.w - 16), + COLOR_TEXT, + ); +} + +fn fit_text_to_control(text: &str, max_width: i32) -> String { + let max_chars = (max_width / 8).max(0) as usize; + if text.chars().count() <= max_chars { + return text.to_string(); + } + + if max_chars <= 2 { + return text.chars().take(max_chars).collect(); + } + + let mut fitted: String = text.chars().take(max_chars - 2).collect(); + fitted.push_str(".."); + fitted +} + fn draw_buffer_text(commands: &mut Vec, x: i32, y: i32, text: &str, color: Color) { commands.push(GfxUiCommand::DrawText { x, y, text: text.to_string(), color }); } @@ -434,6 +507,7 @@ mod tests { use crate::windows::WindowOwner; use prometeu_drivers::TestPlatform; use prometeu_hal::{InputSignals, RuntimePlatform}; + use std::path::PathBuf; fn focused_native_shell_fixture( signals: &InputSignals, @@ -462,7 +536,14 @@ mod tests { #[test] fn shell_a_button_click_emits_launch_action() { assert_eq!( - action_for_click(false, HOME_BUTTONS[0].rect.x, HOME_BUTTONS[0].rect.y, true, &[]), + action_for_click( + false, + HOME_BUTTONS[0].rect.x, + HOME_BUTTONS[0].rect.y, + true, + &[], + None + ), Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellA)) ); } @@ -475,7 +556,8 @@ mod tests { SHELL_B_BUTTON.x + SHELL_B_BUTTON.w - 1, SHELL_B_BUTTON.y, true, - &[] + &[], + None ), Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellB)) ); @@ -489,7 +571,8 @@ mod tests { SHELL_A_BUTTON.x + SHELL_A_BUTTON.w, SHELL_A_BUTTON.y, true, - &[] + &[], + None ), None ); @@ -499,7 +582,8 @@ mod tests { SHELL_A_BUTTON.x, SHELL_A_BUTTON.y + SHELL_A_BUTTON.h, true, - &[] + &[], + None ), None ); @@ -507,7 +591,10 @@ mod tests { #[test] fn held_pointer_does_not_emit_click_action() { - assert_eq!(action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[]), None); + assert_eq!( + action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[], None), + None + ); } #[test] @@ -535,19 +622,35 @@ mod tests { discovered_at: std::time::SystemTime::UNIX_EPOCH, }; + let action = action_for_click( + false, + GAME_ROW_X, + GAME_ROW_Y, + true, + std::slice::from_ref(&entry), + None, + ); + assert_eq!( - action_for_click(false, GAME_ROW_X, GAME_ROW_Y, true, std::slice::from_ref(&entry)), - Some(SystemProfileAction::LaunchGame { path: entry.path.clone() }) + action, + Some(SystemProfileAction::LaunchGame { + target: GameSwitchTarget { + path: entry.path.clone(), + app_id: 42, + title: "Stress".to_string(), + app_version: "1.0.0".to_string(), + } + }) ); } #[test] fn close_button_click_emits_close_only_in_shell() { assert_eq!( - action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), + action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[], None), Some(SystemProfileAction::CloseShell) ); - assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), None); + assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[], None), None); } #[test] @@ -561,7 +664,7 @@ mod tests { #[test] fn home_render_produces_shell_ui_packet_commands() { - let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[]); + let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[], None); assert!(matches!(packet.commands.first(), Some(GfxUiCommand::Clear { .. }))); assert!(packet.commands.iter().any(|command| { @@ -569,6 +672,123 @@ mod tests { })); } + #[test] + fn resident_game_indicator_uses_only_resident_game_sessions() { + let mut os = SystemOS::new(None); + assert_eq!(resident_game_indicator(&mut os), None); + + let shell = os.sessions().create_vm_shell_task(7, "VM Shell"); + assert_eq!(resident_game_indicator(&mut os), None); + os.lifecycle().suspend_task(shell).expect("shell suspend should succeed"); + assert_eq!(resident_game_indicator(&mut os), None); + + let game = os.sessions().create_vm_game_task(42, "Stress Console"); + os.lifecycle().suspend_task(game).expect("game suspend should succeed"); + + assert_eq!( + resident_game_indicator(&mut os), + Some(SuspendedGameIndicator { title: "Stress Console".to_string() }) + ); + } + + #[test] + fn home_render_shows_suspended_game_kill_control_when_game_is_resident() { + let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() }; + let packet = render_home_packet( + HUB_SUSPENDED_GAME_KILL_CONTROL.x, + HUB_SUSPENDED_GAME_KILL_CONTROL.y, + &[], + Some(&suspended_game), + ); + + assert!(packet.commands.iter().any(|command| { + matches!( + command, + GfxUiCommand::FillRect { rect, color } + if *rect == HUB_SUSPENDED_GAME_KILL_CONTROL && *color == COLOR_KILL_ACTIVE + ) + })); + assert!(packet.commands.iter().any(|command| { + matches!( + command, + GfxUiCommand::DrawText { x, y, text, .. } + if *x == HUB_SUSPENDED_GAME_KILL_CONTROL.x + 8 + && *y == HUB_SUSPENDED_GAME_KILL_CONTROL.y + 8 + && text == "Stress Console" + ) + })); + } + + #[test] + fn home_render_omits_suspended_game_kill_control_without_resident_game() { + let packet = render_home_packet( + HUB_SUSPENDED_GAME_KILL_CONTROL.x, + HUB_SUSPENDED_GAME_KILL_CONTROL.y, + &[], + None, + ); + + assert!(!packet.commands.iter().any(|command| { + matches!( + command, + GfxUiCommand::FillRect { rect, color } + if *rect == HUB_SUSPENDED_GAME_KILL_CONTROL + && (*color == COLOR_KILL || *color == COLOR_KILL_ACTIVE) + ) + })); + } + + #[test] + fn suspended_game_kill_control_click_emits_kill_action() { + let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() }; + + assert_eq!( + action_for_click( + false, + HUB_SUSPENDED_GAME_KILL_CONTROL.x, + HUB_SUSPENDED_GAME_KILL_CONTROL.y, + true, + &[], + Some(&suspended_game), + ), + Some(SystemProfileAction::KillResidentGame) + ); + assert_eq!( + action_for_click( + false, + HUB_SUSPENDED_GAME_KILL_CONTROL.x, + HUB_SUSPENDED_GAME_KILL_CONTROL.y, + true, + &[], + None, + ), + None + ); + } + + #[test] + fn suspended_game_kill_control_truncates_long_titles() { + let long_title = + SuspendedGameIndicator { title: "Extremely Long Resident Game Title".to_string() }; + let packet = render_home_packet(0, 0, &[], Some(&long_title)); + let label = packet + .commands + .iter() + .find_map(|command| match command { + GfxUiCommand::DrawText { x, y, text, .. } + if *x == HUB_SUSPENDED_GAME_KILL_CONTROL.x + 8 + && *y == HUB_SUSPENDED_GAME_KILL_CONTROL.y + 8 => + { + Some(text) + } + _ => None, + }) + .expect("kill control text should render"); + + assert!(label.ends_with("..")); + assert!(label.chars().count() <= ((HUB_SUSPENDED_GAME_KILL_CONTROL.w - 16) / 8) as usize); + } + #[test] fn shell_close_button_sits_inside_shell_frame() { assert!(rect_contains(SHELL_FRAME, CLOSE_BUTTON.x, CLOSE_BUTTON.y)); diff --git a/crates/console/prometeu-system/src/services/game_library.rs b/crates/console/prometeu-system/src/services/game_library.rs index bbf9728c..1989e29e 100644 --- a/crates/console/prometeu-system/src/services/game_library.rs +++ b/crates/console/prometeu-system/src/services/game_library.rs @@ -25,6 +25,29 @@ impl GameLibraryEntry { discovered_at, } } + + pub fn switch_target(&self) -> GameSwitchTarget { + GameSwitchTarget::from_entry(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameSwitchTarget { + pub path: PathBuf, + pub app_id: u32, + pub title: String, + pub app_version: String, +} + +impl GameSwitchTarget { + pub fn from_entry(entry: &GameLibraryEntry) -> Self { + Self { + path: entry.path.clone(), + app_id: entry.app_id, + title: entry.title.clone(), + app_version: entry.app_version.clone(), + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -256,6 +279,31 @@ mod tests { })); } + #[test] + fn switch_target_preserves_resolved_identity() { + let path = PathBuf::from("test-cartridges/stress-console"); + let entry = GameLibraryEntry::new( + path.clone(), + CartridgeManifest { + magic: "PMTU".to_string(), + cartridge_version: 1, + app_id: 42, + title: "Stress".to_string(), + app_version: "1.0.0".to_string(), + app_mode: AppMode::Game, + capabilities: vec![], + }, + UNIX_EPOCH, + ); + + let target = entry.switch_target(); + + assert_eq!(target.path, path); + assert_eq!(target.app_id, 42); + assert_eq!(target.title, "Stress"); + assert_eq!(target.app_version, "1.0.0"); + } + #[test] fn missing_root_returns_diagnostic_and_no_entries() { let root = TestRoot::new(); @@ -267,4 +315,22 @@ mod tests { assert_eq!(library.diagnostics().len(), 1); assert_eq!(library.diagnostics()[0].reason, GameLibraryDiagnosticReason::RootNotDirectory); } + + #[test] + fn repository_test_cartridges_include_stress_and_dummy_boy() { + let games_root = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").join("test-cartridges"); + + let library = discover_games_root(games_root); + let entries = library.entries(); + + assert!(entries.iter().any(|entry| { + entry.app_id == 1 + && entry.title == "Stress Console" + && entry.path.ends_with("stress-console") + })); + assert!(entries.iter().any(|entry| { + entry.app_id == 2 && entry.title == "Dummy Boy" && entry.path.ends_with("dummy-boy") + })); + } } diff --git a/crates/console/prometeu-system/src/services/vm_session.rs b/crates/console/prometeu-system/src/services/vm_session.rs index caa023f3..ffd1dbbe 100644 --- a/crates/console/prometeu-system/src/services/vm_session.rs +++ b/crates/console/prometeu-system/src/services/vm_session.rs @@ -121,6 +121,10 @@ impl VmSessionRegistry { self.get_mut(VmSessionId(task_id)) } + pub fn remove_for_task(&mut self, task_id: TaskId) -> Option { + self.sessions.remove(&VmSessionId(task_id)) + } + pub fn resident_game(&self) -> Option<&VmSession> { self.sessions.values().find(|session| session.app_mode == AppMode::Game) } @@ -231,6 +235,31 @@ mod tests { assert_eq!(registry.resident_game_for_app(43).map(|session| session.task_id), None); } + #[test] + fn remove_for_task_removes_only_matching_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"); + + let removed = registry.remove_for_task(game_task.id).expect("game session should remove"); + + assert_eq!(removed.task_id, game_task.id); + assert_eq!(registry.for_task(game_task.id).map(|session| session.task_id), None); + assert_eq!( + registry.for_task(shell_task.id).map(|session| session.task_id), + Some(shell_task.id) + ); + } + #[test] fn session_runtime_state_is_independent_per_session() { let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame); diff --git a/crates/host/prometeu-host-desktop-winit/src/runner.rs b/crates/host/prometeu-host-desktop-winit/src/runner.rs index 0bb7611e..9161e07e 100644 --- a/crates/host/prometeu-host-desktop-winit/src/runner.rs +++ b/crates/host/prometeu-host-desktop-winit/src/runner.rs @@ -26,16 +26,22 @@ const IDLE_HOST_POLL_DT: Duration = Duration::from_millis(100); #[derive(Debug, Clone)] struct PresentationState { - latest_published_frame: u64, - last_presented_frame: Option, + latest_published_frame: Option, + last_presented_frame: Option, host_invalidated: bool, redraw_requested: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PresentationFrameMarker { + ownership: RenderOwnership, + frame_id: u64, +} + impl Default for PresentationState { fn default() -> Self { Self { - latest_published_frame: 0, + latest_published_frame: None, last_presented_frame: None, host_invalidated: true, redraw_requested: false, @@ -44,9 +50,10 @@ impl Default for PresentationState { } impl PresentationState { - fn note_published_frame(&mut self, frame_index: u64) { - if frame_index > self.latest_published_frame { - self.latest_published_frame = frame_index; + fn note_published_frame(&mut self, ownership: RenderOwnership, frame_id: u64) { + let marker = PresentationFrameMarker { ownership, frame_id }; + if self.latest_published_frame != Some(marker) { + self.latest_published_frame = Some(marker); } } @@ -55,7 +62,7 @@ impl PresentationState { } fn needs_redraw(&self) -> bool { - self.host_invalidated || self.last_presented_frame != Some(self.latest_published_frame) + self.host_invalidated || self.last_presented_frame != self.latest_published_frame } fn should_request_redraw(&mut self) -> bool { @@ -70,7 +77,7 @@ impl PresentationState { fn mark_presented(&mut self) { self.redraw_requested = false; self.host_invalidated = false; - self.last_presented_frame = Some(self.latest_published_frame); + self.last_presented_frame = self.latest_published_frame; } } @@ -437,9 +444,11 @@ impl ApplicationHandler for HostRunner { 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()); + self.presentation + .note_published_frame(worker_frame.ownership, worker_frame.frame_id.get()); } else { - self.presentation.note_published_frame(self.firmware.os.frame_index()); + self.presentation + .note_published_frame(active_ownership, self.firmware.os.frame_index()); } if was_debugger_connected != self.debugger.stream.is_some() @@ -505,14 +514,37 @@ mod tests { #[test] fn presentation_state_requests_redraw_for_new_frame_publication() { + let ownership = RenderOwnership::new(1, AppMode::Game, 1); let mut state = PresentationState::default(); state.mark_presented(); - state.note_published_frame(1); + state.note_published_frame(ownership, 1); assert!(state.should_request_redraw()); state.mark_presented(); - assert_eq!(state.last_presented_frame, Some(1)); + assert_eq!( + state.last_presented_frame, + Some(PresentationFrameMarker { ownership, frame_id: 1 }) + ); + } + + #[test] + fn presentation_state_requests_redraw_when_ownership_changes_with_lower_frame_id() { + let first = RenderOwnership::new(3, AppMode::Game, 1); + let next = RenderOwnership::new(4, AppMode::Game, 2); + let mut state = PresentationState::default(); + + state.note_published_frame(first, 120); + state.mark_presented(); + + state.note_published_frame(next, 0); + + assert!(state.should_request_redraw()); + state.mark_presented(); + assert_eq!( + state.last_presented_frame, + Some(PresentationFrameMarker { ownership: next, frame_id: 0 }) + ); } #[test] @@ -608,11 +640,14 @@ mod tests { let mut presentation = PresentationState::default(); presentation.mark_presented(); let published = store.latest_frame().expect("published frame"); - presentation.note_published_frame(published.frame_id.get()); + presentation.note_published_frame(published.ownership, published.frame_id.get()); assert!(presentation.should_request_redraw()); presentation.mark_presented(); - assert_eq!(presentation.last_presented_frame, Some(9)); + assert_eq!( + presentation.last_presented_frame, + Some(PresentationFrameMarker { ownership: published.ownership, frame_id: 9 }) + ); } #[test] diff --git a/crates/tools/pbxgen-dummy-boy/Cargo.toml b/crates/tools/pbxgen-dummy-boy/Cargo.toml new file mode 100644 index 00000000..7d64f7aa --- /dev/null +++ b/crates/tools/pbxgen-dummy-boy/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pbxgen-dummy-boy" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +prometeu-bytecode = { path = "../../console/prometeu-bytecode" } +prometeu-hal = { path = "../../console/prometeu-hal" } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/tools/pbxgen-dummy-boy/src/lib.rs b/crates/tools/pbxgen-dummy-boy/src/lib.rs new file mode 100644 index 00000000..4cccf568 --- /dev/null +++ b/crates/tools/pbxgen-dummy-boy/src/lib.rs @@ -0,0 +1,147 @@ +use anyhow::Result; +use prometeu_bytecode::assembler::assemble; +use prometeu_bytecode::model::{BytecodeModule, DebugInfo, Export, FunctionMeta, SyscallDecl}; +use prometeu_hal::color::Color; +use std::fs; +use std::path::PathBuf; + +const APP_ID: u32 = 2; +const TITLE: &str = "Dummy Boy"; +const APP_VERSION: &str = "0.1.0"; + +fn asm(s: &str) -> Vec { + assemble(s).expect("assemble") +} + +pub fn generate() -> Result<()> { + let out_dir = cartridge_dir(); + fs::create_dir_all(&out_dir)?; + fs::write(out_dir.join("program.pbx"), build_program())?; + fs::write(out_dir.join("manifest.json"), manifest_json())?; + Ok(()) +} + +pub fn build_program() -> Vec { + let mut rom = Vec::new(); + + rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 1\nADD\nSET_GLOBAL 0")); + rom.extend(asm(&format!("PUSH_I64 {}\nHOSTCALL 0", Color::BLACK.raw()))); + + rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 3\nMOD\nPUSH_I32 0\nEQ")); + let jump_to_green_case_offset = rom.len() + 2; + rom.extend(asm("JMP_IF_FALSE 0")); + rom.extend(asm(&format!("PUSH_I64 {}\nSET_LOCAL 0", Color::RED.raw()))); + let jump_to_draw_after_red_offset = rom.len() + 2; + rom.extend(asm("JMP 0")); + + let green_case = rom.len() as u32; + rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 3\nMOD\nPUSH_I32 1\nEQ")); + let jump_to_blue_case_offset = rom.len() + 2; + rom.extend(asm("JMP_IF_FALSE 0")); + rom.extend(asm(&format!("PUSH_I64 {}\nSET_LOCAL 0", Color::GREEN.raw()))); + let jump_to_draw_after_green_offset = rom.len() + 2; + rom.extend(asm("JMP 0")); + + let blue_case = rom.len() as u32; + rom.extend(asm(&format!("PUSH_I64 {}\nSET_LOCAL 0", Color::BLUE.raw()))); + + let draw = rom.len() as u32; + rom.extend(asm(&format!( + "PUSH_I32 180\n\ + PUSH_I32 75\n\ + PUSH_I32 120\n\ + PUSH_I32 120\n\ + PUSH_I64 {}\n\ + GET_LOCAL 0\n\ + HOSTCALL 1\n\ + FRAME_SYNC\n\ + RET", + Color::WHITE.raw() + ))); + + patch_jump(&mut rom, jump_to_green_case_offset, green_case); + patch_jump(&mut rom, jump_to_draw_after_red_offset, draw); + patch_jump(&mut rom, jump_to_blue_case_offset, blue_case); + patch_jump(&mut rom, jump_to_draw_after_green_offset, draw); + + BytecodeModule { + version: 0, + const_pool: vec![], + functions: vec![FunctionMeta { + code_offset: 0, + code_len: rom.len() as u32, + param_slots: 0, + local_slots: 1, + return_slots: 0, + max_stack_slots: 8, + }], + code: rom, + debug_info: Some(DebugInfo { + pc_to_span: vec![], + function_names: vec![(0, "main".into())], + }), + exports: vec![Export { symbol: "main".into(), func_idx: 0 }], + syscalls: vec![ + SyscallDecl { + module: "gfx2d".into(), + name: "clear".into(), + version: 1, + arg_slots: 1, + ret_slots: 0, + }, + SyscallDecl { + module: "gfx2d".into(), + name: "draw_square".into(), + version: 1, + arg_slots: 6, + ret_slots: 0, + }, + ], + } + .serialize() +} + +pub fn manifest_json() -> Vec { + format!( + "{{\n \"magic\": \"PMTU\",\n \"cartridge_version\": 1,\n \"app_id\": {APP_ID},\n \"title\": \"{TITLE}\",\n \"app_version\": \"{APP_VERSION}\",\n \"app_mode\": \"Game\",\n \"capabilities\": [\"gfx2d\"]\n}}\n" + ) + .into_bytes() +} + +fn cartridge_dir() -> PathBuf { + let mut out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + out_dir.pop(); + out_dir.pop(); + out_dir.pop(); + out_dir.push("test-cartridges"); + out_dir.push("dummy-boy"); + out_dir +} + +fn patch_jump(buf: &mut [u8], imm_offset: usize, target: u32) { + buf[imm_offset..imm_offset + 4].copy_from_slice(&target.to_le_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_program_is_pbx_module_with_expected_syscalls() { + let bytes = build_program(); + + assert!(bytes.starts_with(b"PBS\0")); + assert!(bytes.len() > 64); + } + + #[test] + fn manifest_declares_distinct_game_identity() { + let manifest: serde_json::Value = + serde_json::from_slice(&manifest_json()).expect("manifest should parse"); + + assert_eq!(manifest["app_id"], APP_ID); + assert_eq!(manifest["title"], TITLE); + assert_eq!(manifest["app_mode"], "Game"); + assert_eq!(manifest["capabilities"], serde_json::json!(["gfx2d"])); + } +} diff --git a/crates/tools/pbxgen-dummy-boy/src/main.rs b/crates/tools/pbxgen-dummy-boy/src/main.rs new file mode 100644 index 00000000..954d6946 --- /dev/null +++ b/crates/tools/pbxgen-dummy-boy/src/main.rs @@ -0,0 +1,5 @@ +use anyhow::Result; + +fn main() -> Result<()> { + pbxgen_dummy_boy::generate() +} diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 519b2791..6e4ca8a2 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,5 +1,6 @@ -{"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":"meta","next_id":{"DSC":45,"AGD":48,"DEC":41,"PLN":167,"LSN":55,"CLSN":1}} +{"type":"discussion","id":"DSC-0044","status":"done","ticket":"hub-suspended-game-kill-affordance","title":"Hub Suspended Game Kill Affordance","created_at":"2026-07-05","updated_at":"2026-07-05","tags":["hub","lifecycle","game","ui"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0054","file":"discussion/lessons/DSC-0044-hub-suspended-game-kill-affordance/LSN-0054-manual-hub-kill-must-share-game-termination-cleanup.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]} +{"type":"discussion","id":"DSC-0043","status":"done","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0053","file":"discussion/lessons/DSC-0043-system-os-cartridge-switch-orchestrator/LSN-0053-game-switching-is-lifecycle-replacement-not-loader-work.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]} {"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":"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"}]} diff --git a/discussion/lessons/DSC-0043-system-os-cartridge-switch-orchestrator/LSN-0053-game-switching-is-lifecycle-replacement-not-loader-work.md b/discussion/lessons/DSC-0043-system-os-cartridge-switch-orchestrator/LSN-0053-game-switching-is-lifecycle-replacement-not-loader-work.md new file mode 100644 index 00000000..c5f2525b --- /dev/null +++ b/discussion/lessons/DSC-0043-system-os-cartridge-switch-orchestrator/LSN-0053-game-switching-is-lifecycle-replacement-not-loader-work.md @@ -0,0 +1,67 @@ +--- +id: LSN-0053 +ticket: system-os-cartridge-switch-orchestrator +title: Game Switching Is Lifecycle Replacement, Not Loader Work +created: 2026-07-05 +tags: [runtime, os, lifecycle, game, cartridge] +--- + +## Context + +PROMETEU supports two superficially similar flows: + +- `Game -> Home -> same Game`, which is pause/suspend/resume. +- `Game A -> Home -> Game B`, which is destructive replacement. + +The second flow is not a loader feature. It is a SystemOS lifecycle operation +that happens to use the cartridge loader after the old resident Game has been +removed from resumable state. + +## Key Decisions + +### SystemOS Game Cartridge Switch Orchestrator + +**What:** A different-Game launch from Home terminates the previous resident +Game before loading the new cartridge. The new Game receives a fresh VM session. + +**Why:** Reusing or preserving the old Game while the new Game loads creates a +rollback problem across VM state, handles, assets, input, audio, render +ownership, debug state, and lifecycle delivery. V1 does not need that +transactional model. + +**Trade-offs:** Failed replacement returns to Home and does not restore the old +Game. This is intentionally simpler than snapshot/rollback and makes state +ownership visible in tests. + +## Patterns and Algorithms + +Use a resolved SystemOS switch target, not a guest syscall, for Home launches. +The target carries cartridge path and identity, while SystemOS owns the policy: + +1. detect whether the selected Game matches the resident `app_id`; +2. resume if it is the same Game; +3. terminate the old resident Game if it is different; +4. load the new cartridge through the normal loader; +5. create and initialize a fresh VM session; +6. return to Home on replacement failure without restoring the previous Game. + +The loader stays a materializer. It does not close tasks, remove sessions, +repair foreground state, or own failure recovery. + +## Pitfalls + +- Treating a different Game as a resume path leaks VM execution state. +- Letting the loader own lifecycle cleanup hides policy in the wrong layer. +- Keeping the old Game as rollback state requires correctness across assets, + render ownership, input barriers, audio, open handles, and debugger state. +- A switch test with only one cartridge cannot prove identity replacement. + +## Takeaways + +- Same-Game Home selection is resume; different-Game Home selection is + destructive replacement. +- SystemOS owns cartridge switching policy; the cartridge loader only loads. +- Replacement failure should be a clean Home state, not a rollback to the old + Game. +- End-to-end switch tests need visually distinct cartridges to catch stale + session or stale frame reuse. diff --git a/discussion/lessons/DSC-0044-hub-suspended-game-kill-affordance/LSN-0054-manual-hub-kill-must-share-game-termination-cleanup.md b/discussion/lessons/DSC-0044-hub-suspended-game-kill-affordance/LSN-0054-manual-hub-kill-must-share-game-termination-cleanup.md new file mode 100644 index 00000000..b82c5aa7 --- /dev/null +++ b/discussion/lessons/DSC-0044-hub-suspended-game-kill-affordance/LSN-0054-manual-hub-kill-must-share-game-termination-cleanup.md @@ -0,0 +1,72 @@ +--- +id: LSN-0054 +ticket: hub-suspended-game-kill-affordance +title: Manual Hub Kill Must Share Game Termination Cleanup +created: 2026-07-05 +tags: [hub, lifecycle, game, ui, assets] +--- + +## Context + +After a Game returns to Home, the Game can remain resident and suspended so it +can be resumed later. That state needs a visible, direct way to terminate the +resident Game without launching another cartridge just to force replacement. + +The Hub kill affordance solved the UX gap, but the durable engineering lesson is +the cleanup boundary: manual kill must not become a second, partial termination +path. + +## Key Decisions + +### Hub Suspended Game Kill Affordance + +**What:** Hub renders a red bottom-right control when a Game is resident and +suspended. The control shows only the Game title. Clicking it immediately +terminates the resident Game and keeps the user in Hub. + +**Why:** The user can distinguish "resume this Game" from "discard this resident +Game" without starting a different cartridge. Reusing the canonical termination +path prevents a repeat of stale scene/glyph/asset state bugs. + +**Trade-offs:** V1 has no confirmation step. The destructive nature is carried +by placement, color, and the fact that the target Game is already out of +foreground. + +## Patterns and Algorithms + +Manual Hub kill and destructive Game replacement should call the same resident +Game termination helper. That helper is responsible for the whole runtime +surface: + +- close the Game task; +- stop the process; +- remove the VM session; +- clear resident Game state and task-scoped lifecycle delivery; +- restore Shell/Hub render ownership; +- clear lifecycle audio pause state; +- unbind the Game2D composer scene; +- shut down cartridge-scoped asset manager state and physical banks; +- emit explicit Hub/POS observability for manual kill. + +The Hub itself should emit a high-level action such as `KillResidentGame`; it +should not know how to clean runtime subsystems. + +## Pitfalls + +- A UI-only kill that removes a task but leaves assets or composer binding alive + can still fail later when another Game renders. +- A cleanup-only helper without an end-to-end Hub click test can miss the + interaction between hitbox, foreground ownership, render owner, and no-resume. +- Hard-coded click coordinates in tests make the UI contract harder to maintain; + expose the layout hitbox when tests need to exercise it. + +## Takeaways + +- Visible system controls should produce system actions, not direct subsystem + mutation from UI code. +- Manual kill and Game replacement are variants of the same resident Game + termination contract. +- Cleanup tests should prove both logical state removal and visible-frame + behavior: the control appears before kill and disappears after kill. +- "No resume after kill" is as important as "task closed" because resumability + is the user-facing state. diff --git a/discussion/workflow/agendas/AGD-0044-systemos-cartridge-switch-orchestrator.md b/discussion/workflow/agendas/AGD-0044-systemos-cartridge-switch-orchestrator.md deleted file mode 100644 index e0fe0855..00000000 --- a/discussion/workflow/agendas/AGD-0044-systemos-cartridge-switch-orchestrator.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -id: AGD-0044 -ticket: system-os-cartridge-switch-orchestrator -title: SystemOS Cartridge Switch Orchestrator -status: open -created: 2026-07-03 -resolved: -decision: -tags: [runtime, os, lifecycle, game, cartridge, architecture] ---- - -## Contexto - -A `AGD-0041` cobre o caso `Game -> Home/Shell -> mesmo Game`: o usuario sai do -foreground do jogo, o SO assume a tela, Shell/Hub pode rodar, e depois o mesmo -Game e retomado. - -Existe um segundo caso, diferente, que nao deve ser escondido dentro da -`AGD-0041`: `Game A -> Home -> Game B`. - -Nesse fluxo, o usuario esta com um Game rodando, aperta Home, volta para o SO e -pede para iniciar outro jogo. O SystemOS nao deve simplesmente chamar um loader -por cima do estado atual. Ele precisa orquestrar a substituicao do app corrente: -fechar ou descartar o Game residente, limpar estado cartridge-scoped, resolver o -novo alvo, carregar o proximo cartucho e entrar no novo estado de firmware. - -Essa agenda tambem se relaciona com a `AGD-0003 / system-run-cart`: `run_cart`, -se existir, nao e userland e nao e o orquestrador. O carregador de cartucho e -apenas uma etapa dentro de uma transicao comandada pelo SystemOS. - -## Problema - -Sem um orquestrador explicito de troca de cartucho no SystemOS, a plataforma -pode cair em estados incoerentes: - -- carregar um novo Game sem fechar corretamente o Game anterior; -- reaproveitar estado VM, assets, input, audio, render ou telemetria do cartucho - anterior; -- misturar boot direto por linha de comando com troca de app sob o SO; -- deixar o loader decidir lifecycle, quando ele deveria apenas materializar o - cartucho alvo; -- tentar implementar `Home -> outro Game` antes de existir o contrato de - foreground/pausa definido pela `AGD-0041`. - -## Pontos Criticos - -- Esta agenda deve ser executada depois da `AGD-0041`. -- `Game -> Home -> mesmo Game` fica fora desta agenda. -- O SystemOS deve ser a autoridade da troca entre cartuchos sob o SO. -- O loader nao deve possuir politica de fechamento, pausa, foreground ou - cleanup. -- Boot direto por CLI/debug continua sendo caminho separado e mais simples. -- A troca para outro Game deve tratar falha de load sem deixar o sistema em - estado parcialmente substituido. -- `.pmc`, catalogo completo e UX final de launcher continuam fora de escopo, - salvo se forem necessarios para definir o contrato minimo. - -## Opcoes - -### Opcao A - Adiar tudo para depois da AGD-0041 - -- **Abordagem:** manter esta agenda como registro de escopo futuro; primeiro - fechar e implementar o contrato `Game -> Home -> mesmo Game`. -- **Pro:** evita misturar pausa/resume com substituicao de cartucho; reduz risco - de reabrir arquitetura durante a `AGD-0041`. -- **Contra:** a plataforma continua sem troca `Home -> outro Game` ate uma etapa - posterior. -- **Manutenibilidade:** alta, porque cada agenda fecha um eixo de lifecycle. - -### Opcao B - Definir agora o contrato conceitual, implementar depois - -- **Abordagem:** fechar uma decisao curta ja dizendo que o orquestrador pertence - ao SystemOS, que `run_cart` nao e userland, e que a execucao depende da - `AGD-0041`; o plano de codigo fica para depois. -- **Pro:** reduz ambiguidade enquanto trabalhamos na `AGD-0041`. -- **Contra:** pode produzir uma decisao com pouca evidencia operacional antes - do contrato de foreground estar fechado. -- **Manutenibilidade:** boa se a decisao for claramente marcada como dependente - da `AGD-0041`. - -### Opcao C - Implementar o orquestrador completo agora - -- **Abordagem:** tentar resolver `Game A -> Home -> Game B` antes ou junto da - `AGD-0041`. -- **Pro:** ataca o fluxo final diretamente. -- **Contra:** mistura pausa/resume, fechamento, cleanup, resolucao de alvo e - falha de load em uma unica frente; alto risco de contrato instavel. -- **Manutenibilidade:** baixa para v1, porque a troca completa depende do - contrato de foreground ainda aberto. - -## Sugestao / Recomendacao - -Recomendo a Opcao A agora, com esta agenda aberta como marcador explicito de -escopo futuro. - -A ordem preferida passa a ser: - -1. fechar a `AGD-0041` para definir foreground, pausa/suspensao, Shell no - foreground e retorno ao mesmo Game; -2. voltar para esta agenda e decidir o contrato do orquestrador de troca de - cartucho no SystemOS; -3. so entao planejar a execucao de `Game A -> Home -> Game B`. - -Se durante a `AGD-0041` aparecer uma decisao que exige antecipar parte do -orquestrador, esta agenda deve ser reaberta/atualizada com a dependencia -concreta em vez de esconder a troca de cartucho dentro da agenda de foreground. - -## Perguntas em Aberto - -- [ ] Depois da `AGD-0041`, qual estado representa um Game que sera descartado - em vez de retomado? -- [ ] O fechamento do Game atual e observavel para o firmware/game, ou e uma - destruicao interna do SystemOS? -- [ ] Quais estados cartridge-scoped devem ser limpos antes do novo load: VM, - assets, FS handles, input, audio, render ownership, telemetry, logs? -- [ ] Se o novo cartucho falha ao carregar, o sistema volta ao Home, mostra uma - tela de erro, ou tenta preservar o Game anterior? -- [ ] Como a resolucao do novo alvo e representada sem depender de catalogo - completo ou `.pmc`? -- [ ] Qual evidencia minima prova `Game A -> Home -> Game B` sem depender de UX - final de launcher? - -## Criterio para Encerrar - -A agenda pode virar decisao quando: - -- a `AGD-0041` tiver fechado o contrato de foreground/pausa/resume; -- estiver claro que o orquestrador pertence ao SystemOS; -- `run_cart`/loader estiver limitado a etapa interna, nao userland; -- existir regra para fechar/descartar o Game atual; -- existir contrato de limpeza de estado cartridge-scoped; -- existir politica para falha de carregamento do novo Game; -- houver criterio de teste minimo para `Game A -> Home -> Game B`. - -## Discussao - -- 2026-07-03: Agenda criada a partir da separacao entre `AGD-0041` - (`Game -> Home -> mesmo Game`) e `AGD-0003` (`run_cart`/loader nao userland). - O novo escopo cobre a troca posterior `Game A -> Home -> Game B`, com - orquestracao pelo SystemOS depois que o contrato de foreground estiver - fechado. - -## Resolution - -Ainda em aberto. diff --git a/docs/specs/runtime/09-events-and-concurrency.md b/docs/specs/runtime/09-events-and-concurrency.md index 4ec0e44a..133b8839 100644 --- a/docs/specs/runtime/09-events-and-concurrency.md +++ b/docs/specs/runtime/09-events-and-concurrency.md @@ -128,6 +128,11 @@ SystemOS resume request scheduler/runtime state. A Game may observe pause/resume, but it must not control whether SystemOS suspends or resumes the VM. +When SystemOS replaces one Game with another from Home, the old Game MAY receive +a cooperative terminate/save lifecycle event if the runtime supports such an +event. That event is non-blocking in v1. SystemOS remains authoritative and may +close the old Game task/session without guest acknowledgement. + 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 @@ -163,6 +168,20 @@ 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. +During destructive Game replacement, the old Game VM session MUST be removed +from execution eligibility and resumable state before the new Game becomes the +foreground Game. The new Game MUST receive a fresh VM session. Failure while +loading or initializing the replacement Game MUST return the machine to +Hub/Home and MUST NOT restore the previous Game in v1. + +Manual Hub termination of a suspended resident Game has the same session +removal authority as destructive Game replacement, except that no replacement +Game is started. SystemOS MUST remove the Game from execution eligibility and +resumable state, close its task, stop its process, remove its VM session, clear +task-scoped pending lifecycle delivery, and keep Hub/Home as the foreground +owner. After manual Hub termination, resume queries MUST report no resident +Game. A later launch of the same cartridge is a fresh Game session. + ## 9 Render Worker Concurrency The render worker is not a machine-visible event source and does not introduce diff --git a/docs/specs/runtime/10-debug-inspection-and-profiling.md b/docs/specs/runtime/10-debug-inspection-and-profiling.md index 4c9ed75a..47c74456 100644 --- a/docs/specs/runtime/10-debug-inspection-and-profiling.md +++ b/docs/specs/runtime/10-debug-inspection-and-profiling.md @@ -44,6 +44,11 @@ PROMETEU operates in three main modes: No mode alters the logical result of the program. +Debug boot is a host-selected direct cartridge boot profile. It follows the same +cartridge/app-mode resolution path as direct boot and is separate from +Home-driven Game switching. Debug boot MUST NOT be modeled as a guest-visible +Game switch request and MUST NOT reuse a Home switch failure-recovery contract. + ## 3 Execution Control ### 3.1 Pause and Resume diff --git a/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md b/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md index b75246c9..c78311fa 100644 --- a/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md +++ b/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md @@ -43,6 +43,9 @@ 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. +- **Game switch target**: a SystemOS-owned launch target derived from a library + entry. It carries the cartridge path plus resolved identity such as `app_id`, + `title`, and `app_version`. - **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 @@ -52,6 +55,8 @@ The VM does not own the machine lifecycle. Firmware does. 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. +- **Hub resident Game kill control**: a Hub/Home affordance that terminates the + current resident Game instead of resuming it. ## 3 POS Responsibilities @@ -86,6 +91,8 @@ PrometeuHub is responsible for: - presenting the host-provided local games library when `--games-root ` is configured; - emitting system-owned launch actions for selected Home entries. +- presenting a resident Game kill control while Hub/Home owns foreground and a + Game is resident/suspended. The Hub does not execute bytecode directly. It always delegates execution setup to POS. @@ -216,6 +223,17 @@ 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. +When Hub/Home is foreground and a Game cartridge remains resident/suspended, +PrometeuHub MUST render a red control in the bottom-right of the Hub viewport. +The control MUST display only the resident Game title. It MUST NOT be rendered +for no resident Game, Shell tasks, System tasks, or native Shell tasks. + +Clicking the Hub resident Game kill control is a system-owned immediate +termination request. Firmware MUST keep the user in Hub/Home, MUST NOT ask for +confirmation in v1, and MUST NOT resume the killed Game on subsequent Home +actions. The control MUST disappear on the next Hub frame after the resident +Game is no longer present. + ## 8 Cartridge Load Flow Current high-level flow: @@ -231,11 +249,51 @@ Current high-level flow: 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 +resident Game from Home resumes that session. Selecting a different Game from +Home is a SystemOS-owned Game switch and MUST be destructive replacement: + +```text +Game A resident + -> Hub/Home selected Game B with different app_id + -> SystemOS terminates Game A resident task/session + -> SystemOS removes Game A from resumable state + -> firmware loads Game B through LoadCartridge + -> Game B receives a fresh VM session +``` + +SystemOS MUST NOT create a second resident Game session. SystemOS MUST NOT +reuse the old Game VM session, transient VM handles, pending lifecycle delivery +state, render ownership, debug pause state, or crash/debug runtime state for the +new Game. Durable app data remains governed by the relevant storage specs and +may still be keyed by `app_id`. + +SystemOS MAY emit a cooperative non-blocking terminate/save lifecycle event +before closing the old Game when such an event exists. Replacement MUST NOT +block on that event in v1. VM-backed Shell loading creates a distinct Shell session and must not overwrite resident Game VM state. +Manual Hub resident Game kill and destructive different-Game replacement MUST +share the canonical resident-Game termination path. That path MUST: + +- close the resident Game task; +- mark its process stopped; +- remove its VM session; +- clear resident Game state and pending Game lifecycle delivery for that task; +- keep or return foreground ownership to Hub/Home; +- transition render ownership to Shell/Hub (`AppMode::Shell`, `app_id = 0`); +- clear the Game lifecycle audio pause state; +- unbind the Game2D frame composer scene; +- shut down cartridge-scoped asset manager state, including slot identity and + physical glyph/scene/sound banks. + +After this operation, the killed Game MUST NOT be resumable. Launching that +same Game again from Home is a fresh cartridge load and a fresh VM session. +Manual Hub kill MUST emit an INFO Hub log with the message shape: + +```text +Resident game killed from Hub: +``` + 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 @@ -248,6 +306,16 @@ 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. +If a destructive Game switch has already begun and the new Game fails to load or +initialize, firmware MUST return to Hub/Home, MUST discard any partial new Game +task/session/window/render/input/audio state, and MUST NOT restore the previous +Game. Direct boot and debug boot failures remain governed by the boot profile +that requested them and may still transition to the crash path. + +Switch validation MUST include two distinct Game cartridges. The current +repository evidence uses `Stress Console` and `Dummy Boy`; those cartridges are +test evidence, not required runtime features. + ## 9 Firmware States The current firmware state model includes: @@ -310,6 +378,9 @@ Expected behavior: 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. +- clicking the red bottom-right resident Game kill control terminates the + resident Game, keeps Home foreground, clears runtime/cartridge residue, and + removes the resume path. ## 13 Relationship to Other Specs diff --git a/docs/specs/runtime/13-cartridge.md b/docs/specs/runtime/13-cartridge.md index 6f8c1158..a53181bf 100644 --- a/docs/specs/runtime/13-cartridge.md +++ b/docs/specs/runtime/13-cartridge.md @@ -186,6 +186,9 @@ For the v1 Home games library: - the manifest must declare `app_mode: "Game"`; - the library retains the loaded manifest data, `title`, `app_id`, `app_version`, the internal cartridge path, and discovery metadata; +- the library entry identity is the input to the SystemOS Game switch target; +- `app_id` is the v1 identity used to distinguish same-Game resume from + different-Game destructive replacement; - invalid candidates are logged or reported as diagnostics and omitted from the Home list; - valid non-Game cartridges are omitted from the Game library. @@ -193,6 +196,11 @@ For the v1 Home games library: Discovery reads metadata for cataloging. It does not replace the normal cartridge loader used when firmware starts the selected cartridge. +Selecting a discovered Game from Home is not cartridge self-execution and is not +guest-controlled. The cartridge path is consumed by firmware/SystemOS, which +owns same-Game resume, different-Game replacement, failure recovery, and VM +session creation policy. + ### Packaged `.pmc` form This form is recognized conceptually by the loader boundary, but its actual load path is not implemented yet. diff --git a/docs/specs/runtime/14-boot-profiles.md b/docs/specs/runtime/14-boot-profiles.md index a420804c..a2529762 100644 --- a/docs/specs/runtime/14-boot-profiles.md +++ b/docs/specs/runtime/14-boot-profiles.md @@ -97,9 +97,11 @@ The v1 games root contract is: and transitions through `LoadCartridge`; - launch failure leaves or returns the machine to Home and records an error. -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. +Game-to-game switching after a game is already active is not a boot profile. +That orchestration belongs to the foreground/home and cartridge switch +contracts. In that path, SystemOS owns the switch policy: selecting the same +resident Game resumes it, while selecting a different Game performs destructive +replacement and creates a fresh VM session for the new Game. The foreground/home contract defines `Game -> Home/Shell -> same Game` after a Game is already running. It does not change direct `--run`, debugger direct @@ -108,6 +110,10 @@ 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. +Direct boot and debug boot MUST NOT be treated as failed-switch recovery paths: +load or initialization failures for direct boot may transition to the crash +flow, while Home-driven switch failures return to Home and do not restore the +previous Game. ## 5 Firmware State Relationship @@ -152,6 +158,10 @@ Direct single-cartridge boot remains a separate validation path: cargo run --release -p prometeu-host-desktop-winit -- --run test-cartridges/stress-console ``` +Game switch validation should cover the Home-driven two-cartridge path, for +example `Stress Console -> Home -> Dummy Boy`, and assert that the first Game is +not resumable after replacement. + ## 8 Relationship to Other Specs - [`12-firmware-pos-and-prometeuhub.md`](12-firmware-pos-and-prometeuhub.md) defines the firmware state machine that consumes boot targets. diff --git a/test-cartridges/dummy-boy/README.md b/test-cartridges/dummy-boy/README.md new file mode 100644 index 00000000..4739363a --- /dev/null +++ b/test-cartridges/dummy-boy/README.md @@ -0,0 +1,11 @@ +# Dummy Boy + +Regenerate this cartridge with: + +```sh +cargo run -p pbxgen-dummy-boy +``` + +Dummy Boy is a minimal visual Game cartridge used by runtime switch tests. It +clears the frame and draws a centered square whose fill color cycles +deterministically across frames. diff --git a/test-cartridges/dummy-boy/manifest.json b/test-cartridges/dummy-boy/manifest.json new file mode 100644 index 00000000..936959f4 --- /dev/null +++ b/test-cartridges/dummy-boy/manifest.json @@ -0,0 +1,9 @@ +{ + "magic": "PMTU", + "cartridge_version": 1, + "app_id": 2, + "title": "Dummy Boy", + "app_version": "0.1.0", + "app_mode": "Game", + "capabilities": ["gfx2d"] +} diff --git a/test-cartridges/dummy-boy/program.pbx b/test-cartridges/dummy-boy/program.pbx new file mode 100644 index 00000000..ee2844a8 Binary files /dev/null and b/test-cartridges/dummy-boy/program.pbx differ