diff --git a/crates/console/prometeu-firmware/src/firmware/firmware.rs b/crates/console/prometeu-firmware/src/firmware/firmware.rs index 81e3e8f2..bfeaac3e 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware.rs @@ -299,6 +299,18 @@ mod tests { 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"); @@ -314,8 +326,8 @@ mod tests { ); 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 @@ -807,6 +819,73 @@ mod tests { } } + #[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(); 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 93c59dd6..63bf9250 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 @@ -83,9 +83,9 @@ impl HubHomeStep { } } - return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new( - cartridge, - ))); + return Some(FirmwareState::LoadCartridge( + LoadCartridgeStep::new_game_switch(cartridge), + )); } Err(error) => { ctx.os.log( 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..ea2e0cfe 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,5 +1,5 @@ use crate::firmware::firmware_state::{ - AppCrashesStep, FirmwareState, GameRunningStep, ShellRunningStep, + AppCrashesStep, FirmwareState, GameRunningStep, HubHomeStep, ShellRunningStep, }; use crate::firmware::prometeu_context::PrometeuContext; use prometeu_hal::app_mode::AppMode; @@ -16,11 +16,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) { @@ -51,6 +68,34 @@ 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 _ = ctx.os.lifecycle().terminate_resident_game(); + } + } + + 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/discussion/index.ndjson b/discussion/index.ndjson index 204218d2..f3184ac2 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,5 +1,5 @@ {"type":"meta","next_id":{"DSC":44,"AGD":47,"DEC":40,"PLN":163,"LSN":53,"CLSN":1}} -{"type":"discussion","id":"DSC-0043","status":"in_progress","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":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0039","file":"DEC-0039-systemos-game-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0044"}],"plans":[{"id":"PLN-0157","file":"PLN-0157-introduce-systemos-game-switch-target-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0158","file":"PLN-0158-terminate-resident-game-before-switching-cartridges.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0159","file":"PLN-0159-recover-failed-game-switches-back-to-home.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0160","file":"PLN-0160-add-dummy-boy-visual-switch-test-cartridge.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0161","file":"PLN-0161-validate-stress-to-dummy-boy-game-switch-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0162","file":"PLN-0162-specify-game-switch-contract-and-direct-boot-separation.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0043","status":"in_progress","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":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0039","file":"DEC-0039-systemos-game-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0044"}],"plans":[{"id":"PLN-0157","file":"PLN-0157-introduce-systemos-game-switch-target-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0158","file":"PLN-0158-terminate-resident-game-before-switching-cartridges.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0159","file":"PLN-0159-recover-failed-game-switches-back-to-home.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0160","file":"PLN-0160-add-dummy-boy-visual-switch-test-cartridge.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0161","file":"PLN-0161-validate-stress-to-dummy-boy-game-switch-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0162","file":"PLN-0162-specify-game-switch-contract-and-direct-boot-separation.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]}],"lessons":[]} {"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."} {"type":"discussion","id":"DSC-0040","status":"done","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-06","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0048","file":"discussion/lessons/DSC-0040-vm-render-parallel-execution-boundary/LSN-0048-render-workers-need-a-closed-packet-contract.md","status":"done","created_at":"2026-06-06","updated_at":"2026-06-06"}]} {"type":"discussion","id":"DSC-0041","status":"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/workflow/plans/PLN-0159-recover-failed-game-switches-back-to-home.md b/discussion/workflow/plans/PLN-0159-recover-failed-game-switches-back-to-home.md index 06dba643..560a08c4 100644 --- a/discussion/workflow/plans/PLN-0159-recover-failed-game-switches-back-to-home.md +++ b/discussion/workflow/plans/PLN-0159-recover-failed-game-switches-back-to-home.md @@ -2,7 +2,7 @@ id: PLN-0159 ticket: system-os-cartridge-switch-orchestrator title: Recover Failed Game Switches Back To Home -status: open +status: done created: 2026-07-05 ref_decisions: [DEC-0039] tags: [runtime, os, lifecycle, game, cartridge, architecture]