Compare commits

..

10 Commits

Author SHA1 Message Date
de53eb5ad7
Hub Suspended Game Kill Affordance
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
Intrepid/Prometeu/Runtime/pipeline/pr-master This commit looks good
2026-07-05 16:27:05 +01:00
49cb16b6cb
implements PLN-0166 2026-07-05 09:42:57 +01:00
3aec829c70
implements PLN-0165 2026-07-05 09:41:01 +01:00
73c8246c8d
implements PLN-0163 2026-07-05 09:36:59 +01:00
fa0a768b84
implements PLN-0164 2026-07-05 09:31:27 +01:00
62573e9574
Hub Suspended Game Kill Affordance 2026-07-05 09:25:01 +01:00
c814e057dc
implements PLN-0162 2026-07-05 01:26:42 +01:00
8d719843c7
implements PLN-0161 2026-07-05 01:24:45 +01:00
d6afa6ae6b
implements PLN-0160 2026-07-05 01:18:33 +01:00
3df8227ebf
implements PLN-0159 2026-07-05 01:14:45 +01:00
38 changed files with 1411 additions and 947 deletions

10
Cargo.lock generated
View File

@ -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"

View File

@ -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",

View File

@ -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<dyn GlyphBankPoolInstaller>;
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
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());

View File

@ -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<GlyphBank>);
/// 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<SoundBank>);
/// 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<SceneBank>);
/// 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 {

View File

@ -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;
@ -299,6 +302,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<Vec<u8>>,
) -> PathBuf {
let cart = self.path.join(name);
fs::create_dir_all(&cart).expect("test cartridge should be created");
@ -314,8 +329,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
@ -346,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,
@ -460,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);
@ -807,6 +832,433 @@ mod tests {
}
}
#[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();
@ -989,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();
@ -1020,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),
@ -1049,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(_)));
@ -1061,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 };

View File

@ -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,
@ -83,9 +88,9 @@ impl HubHomeStep {
}
}
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(
cartridge,
)));
return Some(FirmwareState::LoadCartridge(
LoadCartridgeStep::new_game_switch(cartridge),
));
}
Err(error) => {
ctx.os.log(
@ -101,6 +106,18 @@ impl HubHomeStep {
}
}
}
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 kill resident game from Hub: {error:?}"),
);
}
}
Some(SystemProfileAction::CloseShell) | None => {}
}

View File

@ -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<CrashReport>,
loaded_task_id: Option<prometeu_system::task::TaskId>,
loaded_app_mode: Option<AppMode>,
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<FirmwareState> {
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 }));
}

View File

@ -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;

View File

@ -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<ResidentGameTerminationOutcome, LifecycleError> {
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(|| "<unknown>".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 })
}

View File

@ -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,

View File

@ -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<TaskId> {
self.os.task_manager.foreground_task()
}
}

View File

@ -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,
};

View File

@ -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,
};

View File

@ -1,5 +1,6 @@
use crate::task::TaskId;
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::{
@ -19,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 };
@ -31,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,6 +87,7 @@ impl NativeShellApp {
pub enum SystemProfileAction {
LaunchNativeShell(NativeShellApp),
LaunchGame { target: GameSwitchTarget },
KillResidentGame,
CloseShell,
}
@ -131,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);
}
@ -149,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() {
@ -232,6 +245,7 @@ fn action_for_click(
y: i32,
pressed: bool,
game_entries: &[crate::GameLibraryEntry],
suspended_game: Option<&SuspendedGameIndicator>,
) -> Option<SystemProfileAction> {
if !pressed {
return None;
@ -241,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)
@ -257,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<SuspendedGameIndicator> {
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,
@ -281,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 });
@ -320,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)
}
@ -422,6 +463,39 @@ fn draw_game_row(commands: &mut Vec<GfxUiCommand>, 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<GfxUiCommand>,
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<GfxUiCommand>, x: i32, y: i32, text: &str, color: Color) {
commands.push(GfxUiCommand::DrawText { x, y, text: text.to_string(), color });
}
@ -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,8 +622,14 @@ 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));
let action = action_for_click(
false,
GAME_ROW_X,
GAME_ROW_Y,
true,
std::slice::from_ref(&entry),
None,
);
assert_eq!(
action,
@ -554,10 +647,10 @@ mod tests {
#[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]
@ -571,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| {
@ -579,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));

View File

@ -315,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")
}));
}
}

View File

@ -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<u64>,
latest_published_frame: Option<PresentationFrameMarker>,
last_presented_frame: Option<PresentationFrameMarker>,
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]

View File

@ -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"

View File

@ -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<u8> {
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<u8> {
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<u8> {
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"]));
}
}

View File

@ -0,0 +1,5 @@
use anyhow::Result;
fn main() -> Result<()> {
pbxgen_dummy_boy::generate()
}

View File

@ -1,5 +1,6 @@
{"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":"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"}]}

View File

@ -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.

View File

@ -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.

View File

@ -1,178 +0,0 @@
---
id: AGD-0044
ticket: system-os-cartridge-switch-orchestrator
title: SystemOS Cartridge Switch Orchestrator
status: accepted
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
`DSC-0041` foi fechada e o contrato de foreground/pausa/resume ja existe. A
recomendacao agora deixa de ser adiar e passa a ser fechar uma decisao curta
para o orquestrador de troca `Game A -> Home -> Game B`.
O contrato sugerido e:
- trocar para outro Game e uma substituicao controlada, nao uma pausa/resume;
- o Game residente anterior e fechado/descartado;
- a VM session do Game anterior e removida;
- o novo Game sempre recebe uma VM session nova e zerada;
- se o novo load falhar, o sistema volta ao Home/Hub com diagnostico e nao
tenta preservar o Game anterior em v1;
- o loader continua sendo etapa interna, sem possuir politica de lifecycle;
- a evidencia minima deve usar dois cartuchos distintos, incluindo um cartucho
simplista `dummy-boy` alem do stress test.
## Perguntas em Aberto
- [x] Depois da `AGD-0041`, qual estado representa um Game que sera descartado
em vez de retomado?
- Resposta: fechamento/encerramento controlado, nao `PausedSuspended`. O Game
anterior deixa de ser resident/resumable e sua VM session e removida.
- [x] O fechamento do Game atual e observavel para o firmware/game, ou e uma
destruicao interna do SystemOS?
- Resposta: deve ser observavel como uma transicao de lifecycle/firmware, mas
nao cooperativo nem bloqueante para o Game. O SystemOS tem autoridade para
encerrar e descartar a sessao. Pode ser emitido um evento de encerramento
para permitir save/flush cooperativo quando existir esse mecanismo, mas a
troca nao deve depender da resposta do Game.
- [x] Quais estados cartridge-scoped devem ser limpos antes do novo load: VM,
assets, FS handles, input, audio, render ownership, telemetry, logs?
- Resposta: a troca nao reaproveita VM. O Game anterior e encerrado, sua VM
session e descartada, e o novo Game recebe VM/session nova e zerada. Devem
ser limpos ou reatribuídos os estados cartridge-scoped ligados a VM,
handles abertos, input pendente/barreiras, audio do app, render ownership,
telemetry/debug/crash state do app. Logs historicos podem permanecer como
observabilidade.
- [x] Se o novo cartucho falha ao carregar, o sistema volta ao Home, mostra uma
tela de erro, ou tenta preservar o Game anterior?
- Resposta: o Game anterior ja foi encerrado. Se o novo load falha, a sessao
parcial do novo Game e descartada e o sistema volta ao Home/Hub com
diagnostico. Preservar o Game anterior fica fora de escopo em v1.
- [x] Como a resolucao do novo alvo e representada sem depender de catalogo
completo ou `.pmc`?
- Resposta: para o contrato minimo, o alvo pode ser um path/entrada ja
resolvido pelo Hub/game library atual. O contrato nao depende de `.pmc` nem
de catalogo final.
- [x] Qual evidencia minima prova `Game A -> Home -> Game B` sem depender de UX
final de launcher?
- Resposta: criar um cartucho `dummy-boy` ao lado do stress test. O teste deve
provar `Stress -> Home -> Dummy Boy`, com troca de VM session, identidade de
cartridge distinta e render observavel do novo Game.
## 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.
- 2026-07-05: Apos fechamento de `DSC-0041`, a discussao convergiu para troca
destrutiva simples em v1: ao iniciar outro Game, o Game residente anterior e
encerrado, sua VM session e removida, e o novo Game recebe VM session nova.
Falha de load volta ao Home/Hub com diagnostico. A evidencia minima deve
incluir um novo cartucho `dummy-boy` que desenha um quadrado com cores
oscilando, alem do stress test.
- 2026-07-05: Refinamento: o encerramento do Game anterior pode emitir evento
cooperativo para save/flush, mas esse evento nao e uma permissao nem uma
barreira obrigatoria. O SystemOS continua com autoridade para concluir a troca
e remover a VM session anterior.
## Resolution
Pronta para virar decisao, pendente confirmacao explicita do usuario.

View File

@ -1,174 +0,0 @@
---
id: DEC-0039
ticket: system-os-cartridge-switch-orchestrator
title: SystemOS Game Cartridge Switch Orchestrator
status: accepted
created: 2026-07-05
ref_agenda: AGD-0044
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Status
Open for review.
## Contexto
`DEC-0037` fechou o contrato `Game -> Home/Shell -> mesmo Game`: o Game pode
ficar residente, ser pausado/suspenso pelo SystemOS, e depois ser retomado.
`DEC-0038` fechou o ownership de VM sessions: VM-backed mutable execution state
pertence a sessoes de VM associadas a processos/tarefas, nao a uma VM global do
firmware.
Esta decision cobre outro fluxo: `Game A -> Home -> Game B`. Nesse caso, o
usuario nao esta retomando o mesmo Game. O usuario esta escolhendo substituir o
Game residente por outro cartucho. Essa substituicao precisa ser orquestrada
pelo SystemOS, nao pelo loader e nao por userland.
## Decisao
PROMETEU MUST tratar `Game A -> Home -> Game B` como uma substituicao
controlada de Game, nao como pause/resume.
Quando o usuario escolher iniciar um Game diferente enquanto ja existe um Game
residente, SystemOS MUST encerrar o Game residente anterior, remover sua
resident state e descartar sua VM session antes de ativar o novo Game como
resident/foreground.
O Game anterior MAY receber um evento cooperativo de encerramento para permitir
save/flush quando esse mecanismo existir. Esse evento MUST NOT ser uma
permissao, veto ou barreira obrigatoria para a troca. SystemOS MUST manter
autoridade para concluir a troca e remover a VM session anterior mesmo se o
Game nao reagir.
O novo Game MUST receber uma VM session nova e zerada. A troca MUST NOT
reutilizar a VM session, PC, stack, heap, globals, open handles, cartridge
identity, lifecycle state, debug state ou runtime state do Game anterior.
O loader/materializador de cartridge MUST permanecer uma etapa interna de
carregamento. Ele MUST NOT possuir politica de lifecycle, foreground,
close/discard, cleanup ou recuperacao de falha.
Se o novo cartucho falhar ao carregar, SystemOS MUST descartar qualquer session
parcial ou estado cartridge-scoped parcial do novo Game e retornar ao Home/Hub
com diagnostico. V1 MUST NOT tentar preservar ou restaurar o Game anterior
depois que a troca tiver comecado.
Boot direto por CLI/debug permanece um caminho separado e mais simples. Esta
decision governa troca de Game sob SystemOS depois que o usuario retorna ao
Home/Hub.
A evidencia minima de implementacao MUST incluir dois test cartridges
distintos: o stress cartridge existente e um novo cartridge `dummy-boy`.
`dummy-boy` MUST renderizar um quadrado visualmente distinto com cor oscilante
para que os testes provem que o segundo Game, e nao um frame/session anterior
stale, esta ativo.
## Rationale
Trying to preserve Game A while loading Game B would turn the switch into a
transactional snapshot/rollback problem. That is not needed for v1 and would
pull in VM snapshot correctness, open handle restoration, assets, render
ownership rollback, audio rollback, input state, debugger state, and telemetry
rollback.
The simpler and safer v1 contract is destructive replacement:
1. the user requests another Game;
2. SystemOS ends the previous resident Game;
3. SystemOS removes its VM session and cartridge-scoped runtime state;
4. SystemOS attempts to load the new Game into a fresh VM session;
5. success enters the new Game;
6. failure returns to Home/Hub with diagnostic.
This aligns with `DEC-0037` because pause/suspend remains the same-Game resume
flow. It aligns with `DEC-0038` because VM identity is session-owned and each
Game receives a distinct VM session.
## Invariantes / Contrato
- SystemOS is the authority for switching from one Game cartridge to another
under Home/Hub.
- `Game -> Home -> same Game` is pause/resume and remains governed by
`DEC-0037`.
- `Game A -> Home -> Game B` is destructive replacement and is governed by this
decision.
- A Game that is being replaced MUST stop being resident/resumable.
- The previous Game VM session MUST be removed before the new Game becomes the
active resident/foreground Game.
- A cooperative close/terminate/save event MAY be delivered to the previous
Game, but it MUST NOT block replacement.
- The new Game MUST get a fresh VM session.
- The new Game MUST NOT inherit VM execution state, open handles, lifecycle
delivery state, cartridge identity, debug state, telemetry state, or crash
state from the previous Game.
- Render ownership MUST transition through SystemOS ownership policy and stale
submissions from the previous Game MUST NOT be presented after the switch.
- Input pending/barrier state MUST be cleared or made safe across the switch.
- App audio from the previous Game MUST be stopped or detached from the active
app before the new Game becomes foreground.
- Logs MAY remain as historical observability; they are not cartridge execution
state.
- If new Game load fails, Home/Hub is the recovery state for v1.
- V1 does not preserve, restore, or rollback to the previous Game after a
switch begins.
- The launcher target MAY be a path or already-resolved game-library entry for
v1. `.pmc` and final catalog UX are out of scope.
- `run_cart`, if retained, is not a userland command and not the switch
orchestrator.
## Impactos
- **Runtime / SystemOS:** needs an explicit Game switch orchestration path that
closes/removes the current resident Game session before loading the next Game.
- **Lifecycle:** needs a controlled replacement transition distinct from
pause/suspend/resume.
- **VM sessions:** previous Game session removal and new Game session creation
are mandatory switch steps.
- **Firmware:** should observe macro states such as Home, loading, load failure,
and GameRunning, but should not own switch policy.
- **Hub / launcher:** should request a switch through SystemOS using a resolved
target; it should not call a loader directly as lifecycle authority.
- **Loader:** remains a materialization step and returns success/failure without
owning cleanup policy.
- **Renderer:** render ownership/stale submission invalidation must prove the
new Game owns current presentation after success.
- **Input/audio:** previous Game input/audio must not leak into the new Game.
- **Tests:** need a second visually distinct test cartridge, `dummy-boy`, to
prove Game identity and render transition.
## Alternativas Descartadas
- **Preserve Game A until Game B loads successfully:** rejected for v1 because it
requires rollback semantics across VM, assets, handles, render ownership,
input, audio, debugger, and telemetry.
- **Treat Game A -> Game B as pause/resume:** rejected because a different Game
should not inherit resumable state from the previous Game.
- **Let the loader close the previous Game:** rejected because loader/materializer
should not own lifecycle policy.
- **Reuse the previous VM session for the new Game:** rejected because it
violates VM session ownership and risks state leakage.
- **Depend on `.pmc` or final catalog UX now:** rejected because the lifecycle
contract can be validated using path/game-library targets first.
## Referencias
- Source agenda: `AGD-0044`
- Foreground stack and Game pause contract: `DEC-0037`
- VM session ownership for VM-backed processes: `DEC-0038`
- SystemOS lifecycle/process/task authority: `DSC-0032`
- Launch authority lesson: `LSN-0051`
- Foreground/session ownership lesson: `LSN-0052`
## Propagacao Necessaria
- Add/update runtime spec for Game cartridge switching under SystemOS.
- Add implementation plans for:
- SystemOS switch orchestration;
- resident Game termination/session removal;
- fresh session load for the new Game;
- failure recovery to Home/Hub;
- `dummy-boy` test cartridge;
- end-to-end `Stress -> Home -> Dummy Boy` validation.
- Ensure host/debug direct boot remains separate from SystemOS Game switch.

View File

@ -1,86 +0,0 @@
---
id: PLN-0157
ticket: system-os-cartridge-switch-orchestrator
title: Introduce SystemOS Game Switch Target Contract
status: done
created: 2026-07-05
ref_decisions: [DEC-0039]
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Briefing
DEC-0039 requires Game-to-Game switching under Home/Hub to be owned by SystemOS.
Before destructive replacement can be implemented, Hub/Firmware/SystemOS need a
small explicit contract for "launch this resolved Game target as a replacement"
that is separate from direct boot and from loader internals.
## Objective
Introduce a SystemOS-owned Game switch request/target abstraction that can carry
a resolved cartridge path or game-library entry from Hub/Home into the switch
orchestrator without making the loader or userland the lifecycle authority.
## Dependencies
- Source decision: DEC-0039.
- Builds on DEC-0037 and DEC-0038 as already implemented.
- Must be implemented before PLN-0158, PLN-0159, and PLN-0161.
## Scope
- Define a minimal Game switch target type, such as `GameSwitchTarget` or
`CartridgeLaunchTarget`, in the SystemOS/Firmware boundary.
- Route Hub game-library launch actions through that target contract instead of
treating a path as an implicit lifecycle operation.
- Keep direct boot/debug boot paths separate from SystemOS Game switching.
- Add tests proving the target preserves cartridge identity/path without
starting lifecycle replacement by itself.
## Non-Goals
- Do not implement destructive replacement in this plan.
- Do not add `.pmc`, final catalog UX, or a new launcher UI.
- Do not change direct CLI/debug boot behavior.
- Do not add background execution.
## Execution Method
1. Inspect `crates/console/prometeu-system/src/services/game_library.rs`,
`crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs`,
and `crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs`.
2. Add a minimal launch target model at the SystemOS/Firmware boundary:
- resolved cartridge path;
- optional app_id/title metadata if already available;
- no loader-owned lifecycle policy.
3. Update Hub action plumbing so `SystemProfileAction::LaunchGame` carries the
resolved target type or maps immediately into it at the firmware boundary.
4. Add a placeholder SystemOS/Firmware entry point for Game switching that
accepts the target but still delegates to the existing load path until
PLN-0158 changes replacement behavior.
5. Add unit tests for target construction and path propagation.
6. Keep direct `BootTarget::Cartridge` behavior unchanged.
## Acceptance Criteria
- There is an explicit type or function contract representing a resolved
SystemOS Game switch target.
- Hub/Home no longer treats a raw path as lifecycle authority.
- The loader remains a materialization step, not the switch orchestrator.
- Direct boot/debug boot code paths remain separate and behaviorally unchanged.
- Tests prove target identity is carried without implicitly closing or loading a
Game.
## Tests
- `cargo test -p prometeu-system`
- `cargo test -p prometeu-firmware`
- `cargo test -p prometeu-host-desktop-winit`
- `cargo clippy -p prometeu-system -p prometeu-firmware -p prometeu-host-desktop-winit --all-targets -- -D warnings`
## Affected Artifacts
- `crates/console/prometeu-system/src/services/game_library.rs`
- `crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs`
- `crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs`
- `crates/console/prometeu-firmware/src/firmware/firmware.rs`

View File

@ -1,98 +0,0 @@
---
id: PLN-0158
ticket: system-os-cartridge-switch-orchestrator
title: Terminate Resident Game Before Switching Cartridges
status: done
created: 2026-07-05
ref_decisions: [DEC-0039]
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Briefing
DEC-0039 defines `Game A -> Home -> Game B` as destructive replacement. The
current same-Game resume flow keeps the resident Game session alive; switching
to a different Game must instead close/remove that resident session and create a
fresh session for the new Game.
## Objective
Implement the SystemOS lifecycle/session operation that terminates the current
resident Game before loading a different Game cartridge.
## Dependencies
- Source decision: DEC-0039.
- Requires PLN-0157 target contract.
- Should be implemented before PLN-0159 and PLN-0161.
## Scope
- Add a controlled resident Game termination/replacement operation to SystemOS
lifecycle/session services.
- Optionally emit a cooperative terminate/close/save lifecycle event when the
current event model can carry it, but do not block replacement on it.
- Remove the previous Game from resident/resumable state.
- Remove the previous Game VM session before activating the new Game.
- Clear or reassign app-scoped state that cannot leak into the new Game:
session VM, open handles, pending lifecycle state, input barrier/pending
input, audio active app state, render ownership, telemetry/debug/crash state.
## Non-Goals
- Do not preserve Game A for rollback.
- Do not implement failed-load recovery beyond surfacing errors; that is
PLN-0159.
- Do not add background execution or multiple resident Games.
- Do not build final launcher UX.
## Execution Method
1. Inspect lifecycle/session state in:
- `crates/console/prometeu-system/src/os/lifecycle.rs`;
- `crates/console/prometeu-system/src/os/facades/sessions.rs`;
- `crates/console/prometeu-system/src/services/foreground`;
- `crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs`;
- `crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs`.
2. Add a `replace_resident_game` or equivalent operation that:
- finds the current resident Game;
- marks its task/process closed or stopped according to existing lifecycle
semantics;
- removes the resident Game entry from foreground stack/lifecycle state;
- removes its VM session.
3. Ensure same-Game resume still bypasses replacement and remains governed by
DEC-0037.
4. Update game launch from Home so launching a different app_id triggers
replacement before load.
5. Ensure render ownership transitions away from the old Game before the new
Game becomes foreground.
6. Add focused tests for resident Game termination and session removal.
## Acceptance Criteria
- Launching a different Game from Home closes/removes the resident Game rather
than resuming it.
- The previous Game VM session is absent before the new Game is made active.
- Same-Game selection still resumes the existing resident Game.
- A cooperative close/terminate event, if added, is non-blocking.
- No previous Game VM, handle, lifecycle, render, or debug state is reused by
the new Game.
## Tests
- `cargo test -p prometeu-system`
- `cargo test -p prometeu-firmware`
- `cargo clippy -p prometeu-system -p prometeu-firmware --all-targets -- -D warnings`
- Add tests for:
- different Game launch removes previous resident session;
- same Game launch resumes existing resident session;
- old Game task/process state is closed/stopped;
- old Game session cannot be resolved after replacement starts.
## Affected Artifacts
- `crates/console/prometeu-system/src/os/lifecycle.rs`
- `crates/console/prometeu-system/src/os/facades/sessions.rs`
- `crates/console/prometeu-system/src/services/foreground`
- `crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs`
- `crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs`

View File

@ -1,86 +0,0 @@
---
id: PLN-0159
ticket: system-os-cartridge-switch-orchestrator
title: Recover Failed Game Switches Back To Home
status: open
created: 2026-07-05
ref_decisions: [DEC-0039]
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Briefing
DEC-0039 rejects preserving Game A after a switch begins. If Game B fails to
load, the system must discard partial Game B state and return to Home/Hub with a
diagnostic, not attempt rollback to Game A.
## Objective
Implement controlled failed-switch recovery to Home/Hub after destructive Game
replacement begins.
## Dependencies
- Source decision: DEC-0039.
- Requires PLN-0157 target contract.
- Requires PLN-0158 resident Game termination/session removal.
## Scope
- Detect load/init failure while switching to a different Game.
- Discard any partial new Game task/session/window/render/input/audio state.
- Return firmware/SystemOS to Home/Hub.
- Log or expose a diagnostic that Home can surface or tests can assert.
- Ensure previous Game is not restored after switch failure.
## Non-Goals
- Do not implement transactional rollback.
- Do not preserve the previous Game after switch begins.
- Do not design final user-facing error UI.
- Do not change direct boot failure behavior unless needed to keep paths
separate.
## Execution Method
1. Inspect existing load failure flow in
`crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs`.
2. Add a switch-specific failure outcome or context flag that distinguishes
SystemOS Game switching from direct boot load.
3. On switch load failure:
- remove partial new VM session/task/process if created;
- clear partial cartridge-scoped state;
- transition to `HubHome` instead of app crash when appropriate for switch
recovery;
- log a diagnostic containing target identity/path and failure summary.
4. Preserve existing direct boot and explicit load failure tests unless a direct
boot path intentionally still goes to crash.
5. Add tests for bad Game B after Game A was already terminated.
## Acceptance Criteria
- Failed `Game A -> Home -> Broken Game B` returns to Home/Hub.
- Previous Game A is not resident, resumable, or restored.
- Partial Game B VM session/task/process state is removed or closed.
- A diagnostic is logged or otherwise observable.
- Existing direct boot/debug boot failure semantics remain separate.
## Tests
- `cargo test -p prometeu-firmware`
- `cargo test -p prometeu-system`
- `cargo test -p prometeu-host-desktop-winit`
- `cargo clippy -p prometeu-system -p prometeu-firmware -p prometeu-host-desktop-winit --all-targets -- -D warnings`
- Add tests for:
- switch failure returns Home;
- old Game not restored;
- partial new session cleanup;
- direct boot failure path remains unchanged.
## Affected Artifacts
- `crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs`
- `crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs`
- `crates/console/prometeu-firmware/src/firmware/firmware.rs`
- `crates/console/prometeu-system/src/os/facades/sessions.rs`
- `crates/console/prometeu-system/src/os/lifecycle.rs`

View File

@ -1,90 +0,0 @@
---
id: PLN-0160
ticket: system-os-cartridge-switch-orchestrator
title: Add Dummy Boy Visual Switch Test Cartridge
status: open
created: 2026-07-05
ref_decisions: [DEC-0039]
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Briefing
DEC-0039 requires minimum evidence using two distinct test cartridges. The
existing stress cartridge proves execution/logging but is not visually distinct
enough for switch validation. A second cartridge, `dummy-boy`, must render an
oscillating-color square.
## Objective
Add a simple `test-cartridges/dummy-boy` cartridge that renders a visible square
with changing colors so Game switching can prove visual/session identity.
## Dependencies
- Source decision: DEC-0039.
- Can be implemented independently of PLN-0157 through PLN-0159.
- Required before PLN-0161 end-to-end validation.
## Scope
- Add `test-cartridges/dummy-boy/manifest.json`.
- Generate or add `test-cartridges/dummy-boy/program.pbx`.
- Add empty or minimal assets payload if the loader requires `assets.pa`.
- Implement Dummy Boy behavior:
- Game cartridge identity distinct from stress;
- deterministic visible square;
- color oscillates across frames;
- no dependency on final launcher UX.
- Add build/generation documentation or tests so the cartridge can be
regenerated safely if needed.
## Non-Goals
- Do not add final game content.
- Do not add `.pmc`.
- Do not depend on host screenshots for the first validation unless an existing
harness already supports it.
- Do not change stress cartridge behavior.
## Execution Method
1. Inspect the stress cartridge and any PBX generator tooling:
- `test-cartridges/stress-console`;
- `crates/tools/pbxgen-stress`;
- bytecode assembler helpers in `crates/console/prometeu-bytecode`.
2. Choose the smallest maintainable generator path:
- extend existing PBX generator if appropriate; or
- add a focused `pbxgen-dummy-boy` tool; or
- add deterministic generated PBX with source instructions.
3. Create `manifest.json` with unique `app_id`, title `Dummy Boy`, Game mode,
and version.
4. Generate `program.pbx` that renders the square and advances color over
frames.
5. Add tests or smoke checks that the cartridge is discoverable by games-root
discovery.
## Acceptance Criteria
- `test-cartridges/dummy-boy` exists and loads as a Game cartridge.
- Dummy Boy has a unique `app_id` and title distinct from Stress.
- Dummy Boy renders a visible square.
- Square color changes deterministically over time.
- Games-root discovery sees both Stress and Dummy Boy.
- Existing stress cartridge remains unchanged.
## Tests
- `cargo test -p prometeu-hal`
- `cargo test -p prometeu-system`
- `cargo test -p prometeu-firmware`
- Run any cartridge generator tests added by this plan.
- Add or update tests for games-root discovery including Dummy Boy.
## Affected Artifacts
- `test-cartridges/dummy-boy/manifest.json`
- `test-cartridges/dummy-boy/program.pbx`
- `test-cartridges/dummy-boy/assets.pa` if required
- `crates/tools/*` cartridge generator tooling if needed
- `crates/console/prometeu-system/src/services/game_library.rs` tests if needed

View File

@ -1,93 +0,0 @@
---
id: PLN-0161
ticket: system-os-cartridge-switch-orchestrator
title: Validate Stress To Dummy Boy Game Switch End To End
status: open
created: 2026-07-05
ref_decisions: [DEC-0039]
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Briefing
DEC-0039 requires proof that `Game A -> Home -> Game B` creates a fresh Game
session and does not present stale state from Game A. The minimum evidence is
switching from Stress to Dummy Boy.
## Objective
Add end-to-end validation for `Stress -> Home -> Dummy Boy` through the real
Hub/Home flow, proving lifecycle, VM session, cartridge identity, and render
ownership switch correctly.
## Dependencies
- Source decision: DEC-0039.
- Requires PLN-0157 target contract.
- Requires PLN-0158 destructive replacement.
- Requires PLN-0159 failed-switch recovery for negative path assertions.
- Requires PLN-0160 Dummy Boy cartridge.
## Scope
- Add firmware/system tests that drive:
- launch Stress from games root;
- request Home;
- launch Dummy Boy;
- assert Stress session is gone;
- assert Dummy Boy session is active;
- assert cartridge identity and render ownership belong to Dummy Boy.
- Add a negative end-to-end test for broken Game B returning to Home if not
already covered by PLN-0159.
- Use existing test platform/render submission inspection where possible.
## Non-Goals
- Do not require final launcher UX.
- Do not add screenshot-based host automation unless existing test utilities
make it cheap and deterministic.
- Do not implement additional Game-to-Game variants beyond Stress to Dummy Boy.
## Execution Method
1. Inspect existing firmware tests in
`crates/console/prometeu-firmware/src/firmware/firmware.rs`.
2. Reuse existing `TestGameRoot` or games-root helpers to expose Stress and
Dummy Boy.
3. Drive input through Hub/Home selection rather than calling cartridge load
directly when possible.
4. Assert lifecycle/session invariants:
- old task closed/stopped;
- old VM session removed;
- new VM session created;
- foreground owner is new Game;
- current cartridge identity is Dummy Boy.
5. Assert render invariants:
- render ownership epoch belongs to Dummy Boy app identity;
- stale Stress submissions are not accepted after switch;
- latest observable Game frame comes from Dummy Boy when render inspection is
available.
6. Add a focused test for failure path if missing after PLN-0159.
## Acceptance Criteria
- End-to-end test proves Stress can be replaced by Dummy Boy from Home.
- Stress is not resumable after switching to Dummy Boy.
- Dummy Boy receives a fresh VM session.
- Cartridge identity and active render ownership are Dummy Boy after switch.
- Failure path returns Home and does not restore Stress.
- Tests are deterministic and do not rely on manual host interaction.
## Tests
- `cargo test -p prometeu-firmware`
- `cargo test -p prometeu-system`
- `cargo test -p prometeu-host-desktop-winit`
- `cargo clippy -p prometeu-system -p prometeu-firmware -p prometeu-host-desktop-winit --all-targets -- -D warnings`
## Affected Artifacts
- `crates/console/prometeu-firmware/src/firmware/firmware.rs`
- `crates/console/prometeu-system/src/services/game_library.rs`
- `test-cartridges/dummy-boy`
- Host/render test utilities if needed

View File

@ -1,93 +0,0 @@
---
id: PLN-0162
ticket: system-os-cartridge-switch-orchestrator
title: Specify Game Switch Contract And Direct Boot Separation
status: open
created: 2026-07-05
ref_decisions: [DEC-0039]
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Briefing
DEC-0039 must be reflected in the canonical runtime specs after the operational
contract is implemented or alongside implementation. The spec must distinguish
same-Game resume, Game replacement under SystemOS, and direct boot/debug boot.
## Objective
Update the canonical runtime documentation to describe SystemOS Game cartridge
switching and to keep direct boot/debug boot separate from Home/Hub-driven Game
replacement.
## Dependencies
- Source decision: DEC-0039.
- Can be implemented before code if the spec is written as intended behavior.
- Should be reconciled after PLN-0157 through PLN-0161 if implementation names
differ from initial plan names.
## Scope
- Update relevant files under `docs/specs/runtime/`, especially:
- `12-firmware-pos-and-prometeuhub.md`;
- `13-cartridge.md`;
- `14-boot-profiles.md`;
- `09-events-and-concurrency.md` if lifecycle events need mention;
- `10-debug-inspection-and-profiling.md` if debugger separation needs note.
- Document:
- SystemOS authority for Game switching;
- destructive replacement semantics;
- optional non-blocking terminate/save event;
- fresh VM session for new Game;
- failed switch recovery to Home/Hub;
- direct boot/debug boot separation;
- test cartridge evidence requirement.
## Non-Goals
- Do not document `.pmc` or final catalog UX as required.
- Do not specify background execution.
- Do not revise DEC-0037 or DEC-0038 semantics.
- Do not write lesson material; housekeeping will decide lessons after
execution.
## Execution Method
1. Read the runtime spec files listed in scope.
2. Add a Game switching section in the most appropriate canonical spec file.
3. Cross-reference existing Home/Hub, cartridge, boot profile, and debug boot
sections.
4. Ensure language is English and normative enough for future implementers:
`MUST`, `MUST NOT`, `MAY`, `SHOULD`.
5. Verify no non-English agenda language leaks into specs.
6. Run docs grep checks for the newly introduced terms.
## Acceptance Criteria
- Specs explicitly distinguish:
- same-Game pause/resume;
- Game A to Game B destructive replacement;
- direct boot/debug boot.
- Specs state that SystemOS owns Game switching policy.
- Specs state that a new Game gets a fresh VM session.
- Specs state that failed switch returns Home/Hub and does not restore the old
Game in v1.
- Specs mention Dummy Boy or equivalent two-cartridge evidence only as test
evidence, not as runtime feature.
- Documentation is English.
## Tests
- `rg -n "Game switch|destructive replacement|direct boot|Dummy Boy|VM session" docs/specs/runtime`
- `discussion validate`
- Run code tests only if this plan is implemented together with code changes:
`cargo test -p prometeu-system -p prometeu-firmware`
## Affected Artifacts
- `docs/specs/runtime/12-firmware-pos-and-prometeuhub.md`
- `docs/specs/runtime/13-cartridge.md`
- `docs/specs/runtime/14-boot-profiles.md`
- `docs/specs/runtime/09-events-and-concurrency.md`
- `docs/specs/runtime/10-debug-inspection-and-profiling.md`

View File

@ -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

View File

@ -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

View File

@ -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 <dir>` 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: <title>
```
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

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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"]
}

Binary file not shown.