From fa0a768b84ac25437d531b1c0a9f50010ac7bc5b Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Sun, 5 Jul 2026 09:31:27 +0100 Subject: [PATCH] implements PLN-0164 --- .../src/firmware/firmware.rs | 111 ++++++++++++++++++ .../src/firmware/firmware_step_hub_home.rs | 7 +- .../firmware/firmware_step_load_cartridge.rs | 7 +- .../prometeu-firmware/src/firmware/mod.rs | 1 + .../src/firmware/resident_game_termination.rs | 42 +++++++ discussion/index.ndjson | 2 +- ...pose-resident-game-termination-contract.md | 2 +- 7 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 crates/console/prometeu-firmware/src/firmware/resident_game_termination.rs diff --git a/crates/console/prometeu-firmware/src/firmware/firmware.rs b/crates/console/prometeu-firmware/src/firmware/firmware.rs index babe09eb..960a58e2 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware.rs @@ -250,6 +250,7 @@ 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::{GlyphBankPoolAccess, SceneBankPoolAccess, TestPlatform}; @@ -258,6 +259,7 @@ mod tests { 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; @@ -988,6 +990,115 @@ mod tests { 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 failed_game_switch_returns_home_without_restoring_previous_game() { 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 63bf9250..7f6740d3 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}; @@ -69,7 +70,11 @@ impl HubHomeStep { return Some(FirmwareState::ResumeResidentGame); } - if let Err(error) = ctx.os.lifecycle().terminate_resident_game() { + if let Err(error) = resident_game_termination::terminate_resident_game( + ctx.os, + ctx.platform, + false, + ) { ctx.os.log( LogLevel::Error, LogSource::Hub, 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 2cbeb823..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 @@ -2,6 +2,7 @@ use crate::firmware::firmware_state::{ 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; @@ -82,7 +83,11 @@ impl LoadCartridgeStep { .unwrap_or(false); if is_partial_target { - let _ = ctx.os.lifecycle().terminate_resident_game(); + let _ = resident_game_termination::terminate_resident_game( + ctx.os, + ctx.platform, + false, + ); } } 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/discussion/index.ndjson b/discussion/index.ndjson index 34c069ce..09a11462 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,5 +1,5 @@ {"type":"meta","next_id":{"DSC":45,"AGD":48,"DEC":41,"PLN":167,"LSN":53,"CLSN":1}} -{"type":"discussion","id":"DSC-0044","status":"in_progress","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":[{"id":"AGD-0047","file":"AGD-0047-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0040","file":"DEC-0040-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0047"}],"plans":[{"id":"PLN-0163","file":"PLN-0163-render-hub-suspended-game-kill-control.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0164","file":"PLN-0164-expose-resident-game-termination-contract.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0165","file":"PLN-0165-validate-hub-kill-flow-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0166","file":"PLN-0166-publish-hub-kill-contract-spec-and-lesson-hooks.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0044","status":"in_progress","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":[{"id":"AGD-0047","file":"AGD-0047-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0040","file":"DEC-0040-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0047"}],"plans":[{"id":"PLN-0163","file":"PLN-0163-render-hub-suspended-game-kill-control.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0164","file":"PLN-0164-expose-resident-game-termination-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0165","file":"PLN-0165-validate-hub-kill-flow-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0166","file":"PLN-0166-publish-hub-kill-contract-spec-and-lesson-hooks.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]}],"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":"done","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":"done","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":"done","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"}]} diff --git a/discussion/workflow/plans/PLN-0164-expose-resident-game-termination-contract.md b/discussion/workflow/plans/PLN-0164-expose-resident-game-termination-contract.md index 9176511b..e16d0535 100644 --- a/discussion/workflow/plans/PLN-0164-expose-resident-game-termination-contract.md +++ b/discussion/workflow/plans/PLN-0164-expose-resident-game-termination-contract.md @@ -2,7 +2,7 @@ id: PLN-0164 ticket: hub-suspended-game-kill-affordance title: Expose Resident Game Termination Contract -status: open +status: done created: 2026-07-05 ref_decisions: [DEC-0040] tags: [hub, lifecycle, game, ui]