dev/system-os-cartridge-switch-orchestrator #36

Merged
bquarkz merged 13 commits from dev/system-os-cartridge-switch-orchestrator into master 2026-07-05 17:47:27 +00:00
34 changed files with 1676 additions and 225 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;
@ -289,20 +292,45 @@ mod tests {
}
fn add_game(&self, name: &str, with_program: bool) -> PathBuf {
self.add_game_with_identity(name, 42, "Stress", "1.0.0", with_program)
}
fn add_game_with_identity(
&self,
name: &str,
app_id: u32,
title: &str,
app_version: &str,
with_program: bool,
) -> PathBuf {
let program = with_program.then(halting_program);
self.add_game_with_program(name, app_id, title, app_version, program)
}
fn add_game_with_program(
&self,
name: &str,
app_id: u32,
title: &str,
app_version: &str,
program: Option<Vec<u8>>,
) -> PathBuf {
let cart = self.path.join(name);
fs::create_dir_all(&cart).expect("test cartridge should be created");
let manifest = r#"{
let manifest = format!(
r#"{{
"magic": "PMTU",
"cartridge_version": 1,
"app_id": 42,
"title": "Stress",
"app_version": "1.0.0",
"app_id": {app_id},
"title": "{title}",
"app_version": "{app_version}",
"app_mode": "Game"
}"#;
}}"#
);
fs::write(cart.join("manifest.json"), manifest)
.expect("test manifest should be written");
if with_program {
fs::write(cart.join("program.pbx"), halting_program())
if let Some(program) = program {
fs::write(cart.join("program.pbx"), program)
.expect("test program should be written");
}
cart
@ -333,6 +361,10 @@ mod tests {
.serialize()
}
fn repository_test_cartridges_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").join("test-cartridges")
}
fn invalid_game_cartridge() -> Cartridge {
Cartridge {
app_id: 7,
@ -447,6 +479,12 @@ mod tests {
windows.set_focus(id);
}
fn front_buffer_pixel(platform: &TestPlatform, x: i32, y: i32) -> u32 {
const FRAMEBUFFER_WIDTH: usize = 480;
let index = x as usize + y as usize * FRAMEBUFFER_WIDTH;
platform.local_hardware().gfx.front_buffer()[index]
}
#[test]
fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() {
let mut firmware = Firmware::new(None);
@ -728,6 +766,499 @@ mod tests {
);
}
#[test]
fn hub_home_clicking_different_game_terminates_previous_resident_session() {
let root = TestGameRoot::new();
root.add_game("stress", true);
root.add_game_with_identity("zzz-dummy", 43, "Zzz Dummy", "1.0.0", true);
let (mut firmware, mut platform) = hub_home_firmware();
firmware.hub.set_game_library(discover_games_root(&root.path));
firmware.tick(&InputSignals::default(), &mut platform);
let stress_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
firmware.tick(&stress_click, &mut platform);
firmware.tick(&InputSignals::default(), &mut platform);
let first_game_task = match &firmware.state {
FirmwareState::GameRunning(step) => step.task_id,
other => panic!("expected first GameRunning state, got {:?}", other),
};
firmware.request_home_from_host();
firmware.tick(&InputSignals::default(), &mut platform);
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(first_game_task));
firmware.tick(&InputSignals::default(), &mut platform);
let dummy_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
firmware.tick(&dummy_click, &mut platform);
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
assert_eq!(
firmware.os.lifecycle().process_state_for_task(first_game_task),
Ok(ProcessState::Stopped)
);
assert_eq!(
firmware
.os
.sessions()
.vm_session_for_task(first_game_task)
.map(|session| session.task_id),
None
);
let second_game_task = firmware
.os
.lifecycle()
.resident_game_task()
.expect("replacement game should be resident");
assert_ne!(second_game_task, first_game_task);
assert_eq!(
firmware
.os
.sessions()
.vm_session_for_task(second_game_task)
.map(|session| session.app_id),
Some(43)
);
firmware.tick(&InputSignals::default(), &mut platform);
match &firmware.state {
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_game_task),
other => panic!("expected replacement GameRunning state, got {:?}", other),
}
}
#[test]
fn repository_stress_to_dummy_boy_switch_is_end_to_end() {
let library = discover_games_root(repository_test_cartridges_root());
assert!(
library
.entries()
.iter()
.any(|entry| entry.app_id == 1 && entry.title == "Stress Console")
);
assert!(
library.entries().iter().any(|entry| entry.app_id == 2 && entry.title == "Dummy Boy")
);
let (mut firmware, mut platform) = hub_home_firmware();
firmware.hub.set_game_library(library);
firmware.tick(&InputSignals::default(), &mut platform);
let stress_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
firmware.tick(&stress_click, &mut platform);
firmware.tick(&InputSignals::default(), &mut platform);
let stress_task = match &firmware.state {
FirmwareState::GameRunning(step) => step.task_id,
other => panic!("expected Stress GameRunning state, got {:?}", other),
};
assert_eq!(firmware.os.vm().current_app_id(), 1);
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console");
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
Some(100)
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
Some(1)
);
firmware.request_home_from_host();
firmware.tick(&InputSignals::default(), &mut platform);
let hub_ownership = firmware.os.vm().active_render_ownership();
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
assert_eq!(hub_ownership.app_id, 0);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
Some(100)
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
Some(1)
);
firmware.tick(&InputSignals::default(), &mut platform);
let dummy_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
firmware.tick(&dummy_click, &mut platform);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
None
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
None
);
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
assert_eq!(
firmware.os.lifecycle().process_state_for_task(stress_task),
Ok(ProcessState::Stopped)
);
assert_eq!(
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
None
);
let dummy_task = firmware
.os
.lifecycle()
.resident_game_task()
.expect("Dummy Boy should be resident after switch load");
assert_ne!(dummy_task, stress_task);
assert_eq!(
firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| {
(session.app_id, session.title.clone(), session.app_version.clone())
}),
Some((2, "Dummy Boy".to_string(), "0.1.0".to_string()))
);
firmware.tick(&InputSignals::default(), &mut platform);
match &firmware.state {
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, dummy_task),
other => panic!("expected Dummy Boy GameRunning state, got {:?}", other),
}
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(dummy_task));
assert_eq!(firmware.os.vm().current_app_id(), 2);
assert_eq!(firmware.os.vm().current_cartridge_title(), "Dummy Boy");
firmware.tick(&InputSignals::default(), &mut platform);
let dummy_ownership = firmware.os.vm().active_render_ownership();
assert_eq!(dummy_ownership.app_mode, AppMode::Game);
assert_eq!(dummy_ownership.app_id, 2);
let front = platform.local_hardware().gfx.front_buffer();
let center = 240 + 135 * 480;
assert_eq!(front[center], Color::GREEN.raw());
firmware.request_home_from_host();
firmware.tick(&InputSignals::default(), &mut platform);
let hub_ownership = firmware.os.vm().active_render_ownership();
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
assert_eq!(hub_ownership.app_id, 0);
firmware.tick(&InputSignals::default(), &mut platform);
let stress_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
firmware.tick(&stress_click, &mut platform);
assert_eq!(firmware.os.lifecycle().task_state(dummy_task), Some(TaskState::Closed));
assert_eq!(
firmware.os.lifecycle().process_state_for_task(dummy_task),
Ok(ProcessState::Stopped)
);
assert_eq!(
firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| session.task_id),
None
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
Some(100)
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
Some(1)
);
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some());
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some());
let second_stress_task = firmware
.os
.lifecycle()
.resident_game_task()
.expect("Stress should be resident after switching back");
assert_ne!(second_stress_task, stress_task);
assert_ne!(second_stress_task, dummy_task);
firmware.tick(&InputSignals::default(), &mut platform);
match &firmware.state {
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_stress_task),
other => panic!("expected second Stress GameRunning state, got {:?}", other),
}
assert_eq!(firmware.os.vm().current_app_id(), 1);
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console");
firmware.tick(&InputSignals::default(), &mut platform);
let stress_ownership = firmware.os.vm().active_render_ownership();
assert_eq!(stress_ownership.app_mode, AppMode::Game);
assert_eq!(stress_ownership.app_id, 1);
}
#[test]
fn resident_game_termination_noops_when_no_game_is_resident() {
let (mut firmware, mut platform) = hub_home_firmware();
let ownership_before = firmware.os.vm().active_render_ownership();
let outcome = resident_game_termination::terminate_resident_game(
&mut firmware.os,
&mut platform,
true,
)
.expect("no resident game termination should not fail");
assert_eq!(outcome, ResidentGameTerminationOutcome::NoResidentGame);
assert_eq!(firmware.os.vm().active_render_ownership(), ownership_before);
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
assert!(
!firmware
.os
.recent_logs(16)
.iter()
.any(|event| event.msg.contains("Resident game killed from Hub"))
);
}
#[test]
fn resident_game_termination_closes_session_and_cleans_runtime_state() {
let library = discover_games_root(repository_test_cartridges_root());
let (mut firmware, mut platform) = hub_home_firmware();
firmware.hub.set_game_library(library);
firmware.tick(&InputSignals::default(), &mut platform);
let stress_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
firmware.tick(&stress_click, &mut platform);
firmware.tick(&InputSignals::default(), &mut platform);
let stress_task = match &firmware.state {
FirmwareState::GameRunning(step) => step.task_id,
other => panic!("expected Stress GameRunning state, got {:?}", other),
};
firmware.request_home_from_host();
firmware.tick(&InputSignals::default(), &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(stress_task));
assert!(firmware.game_lifecycle_audio_paused());
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
Some(100)
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
Some(1)
);
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some());
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some());
let outcome = resident_game_termination::terminate_resident_game(
&mut firmware.os,
&mut platform,
true,
)
.expect("resident game termination should succeed");
assert_eq!(
outcome,
ResidentGameTerminationOutcome::Terminated {
task_id: stress_task,
title: "Stress Console".to_string()
}
);
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
assert_eq!(
firmware.os.lifecycle().process_state_for_task(stress_task),
Ok(ProcessState::Stopped)
);
assert_eq!(
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
None
);
assert!(!firmware.game_lifecycle_audio_paused());
let ownership = firmware.os.vm().active_render_ownership();
assert_eq!(ownership.app_mode, AppMode::Shell);
assert_eq!(ownership.app_id, 0);
assert_eq!(platform.local_hardware().frame_composer.active_scene_id(), None);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
None
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
None
);
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
assert!(
firmware.os.recent_logs(32).iter().any(|event| {
event.level == LogLevel::Info
&& event.source == LogSource::Hub
&& event.msg == "Resident game killed from Hub: Stress Console"
}),
"manual Hub kill should emit an INFO log"
);
}
#[test]
fn hub_kill_control_click_terminates_resident_game_and_stays_home() {
let library = discover_games_root(repository_test_cartridges_root());
let (mut firmware, mut platform) = hub_home_firmware();
firmware.hub.set_game_library(library);
firmware.tick(&InputSignals::default(), &mut platform);
let stress_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
firmware.tick(&stress_click, &mut platform);
firmware.tick(&InputSignals::default(), &mut platform);
let stress_task = match &firmware.state {
FirmwareState::GameRunning(step) => step.task_id,
other => panic!("expected Stress GameRunning state, got {:?}", other),
};
firmware.request_home_from_host();
firmware.tick(&InputSignals::default(), &mut platform);
firmware.tick(&InputSignals::default(), &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(stress_task));
let kill_sample_x = HUB_SUSPENDED_GAME_KILL_CONTROL.x + 2;
let kill_sample_y = HUB_SUSPENDED_GAME_KILL_CONTROL.y + 2;
assert_eq!(
front_buffer_pixel(&platform, kill_sample_x, kill_sample_y),
Color::rgb(164, 32, 40).raw(),
"Hub should render the suspended-game kill control before click"
);
let kill_click = InputSignals {
f_signal: true,
x_pos: HUB_SUSPENDED_GAME_KILL_CONTROL.x,
y_pos: HUB_SUSPENDED_GAME_KILL_CONTROL.y,
..Default::default()
};
firmware.tick(&kill_click, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
assert_eq!(firmware.os.lifecycle().resident_game(), None);
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
assert_eq!(
firmware.os.lifecycle().process_state_for_task(stress_task),
Ok(ProcessState::Stopped)
);
assert_eq!(
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
None
);
assert!(!firmware.game_lifecycle_audio_paused());
assert!(!firmware.resume_resident_game_from_home());
let ownership = firmware.os.vm().active_render_ownership();
assert_eq!(ownership.app_mode, AppMode::Shell);
assert_eq!(ownership.app_id, 0);
assert_eq!(platform.local_hardware().frame_composer.active_scene_id(), None);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
None
);
assert_eq!(
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
None
);
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
assert!(
firmware.os.recent_logs(32).iter().any(|event| {
event.level == LogLevel::Info
&& event.source == LogSource::Hub
&& event.msg == "Resident game killed from Hub: Stress Console"
}),
"Hub kill control should use the canonical manual kill log"
);
firmware.tick(&InputSignals::default(), &mut platform);
assert_eq!(
front_buffer_pixel(&platform, kill_sample_x, kill_sample_y),
Color::rgb(12, 14, 22).raw(),
"Hub should stop rendering the kill control after the game is gone"
);
}
#[test]
fn failed_game_switch_returns_home_without_restoring_previous_game() {
let root = TestGameRoot::new();
root.add_game("stress", true);
root.add_game_with_program("zzz-broken", 43, "Zzz Broken", "1.0.0", Some(vec![0, 0, 0, 0]));
let (mut firmware, mut platform) = hub_home_firmware();
firmware.hub.set_game_library(discover_games_root(&root.path));
firmware.tick(&InputSignals::default(), &mut platform);
let stress_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
firmware.tick(&stress_click, &mut platform);
firmware.tick(&InputSignals::default(), &mut platform);
let first_game_task = match &firmware.state {
FirmwareState::GameRunning(step) => step.task_id,
other => panic!("expected first GameRunning state, got {:?}", other),
};
firmware.request_home_from_host();
firmware.tick(&InputSignals::default(), &mut platform);
firmware.tick(&InputSignals::default(), &mut platform);
let broken_click =
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
firmware.tick(&broken_click, &mut platform);
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
assert_eq!(
firmware
.os
.sessions()
.vm_session_for_task(first_game_task)
.map(|session| session.task_id),
None
);
let partial_task = firmware
.os
.lifecycle()
.resident_game_task()
.expect("failed target should be resident until recovery tick");
assert_ne!(partial_task, first_game_task);
firmware.tick(&InputSignals::default(), &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
assert_eq!(firmware.os.lifecycle().task_state(partial_task), Some(TaskState::Closed));
assert_eq!(
firmware.os.lifecycle().process_state_for_task(partial_task),
Ok(ProcessState::Stopped)
);
assert_eq!(
firmware.os.sessions().vm_session_for_task(partial_task).map(|session| session.task_id),
None
);
assert!(
firmware
.os
.recent_logs(16)
.iter()
.any(|event| event.msg.contains("Failed to switch to game Zzz Broken app_id 43"))
);
}
#[test]
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
let root = TestGameRoot::new();
@ -910,6 +1441,8 @@ mod tests {
};
let tick_index_before_home = firmware.os.vm().tick_index();
let render_ownership_before_home = firmware.os.vm().active_render_ownership();
assert_eq!(render_ownership_before_home.app_mode, AppMode::Game);
assert_eq!(render_ownership_before_home.app_id, 9);
firmware.request_home_from_host();
@ -941,7 +1474,6 @@ mod tests {
let render_ownership_after_home = firmware.os.vm().active_render_ownership();
assert_eq!(render_ownership_after_home.app_mode, AppMode::Shell);
assert_eq!(render_ownership_after_home.app_id, 0);
assert!(render_ownership_after_home.epoch > render_ownership_before_home.epoch);
assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty());
assert_eq!(
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
@ -970,6 +1502,8 @@ mod tests {
firmware.request_home_from_host();
firmware.tick(&signals, &mut platform);
let hub_ownership = firmware.os.vm().active_render_ownership();
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
assert_eq!(hub_ownership.app_id, 0);
assert!(firmware.resume_resident_game_from_home());
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
@ -982,7 +1516,6 @@ mod tests {
let game_ownership = firmware.os.vm().active_render_ownership();
assert_eq!(game_ownership.app_mode, AppMode::Game);
assert_eq!(game_ownership.app_id, 9);
assert!(game_ownership.epoch > hub_ownership.epoch);
let held_signals =
InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals };

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};
@ -52,7 +53,8 @@ impl HubHomeStep {
ctx.os.windows().set_focus(id);
return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id)));
}
Some(SystemProfileAction::LaunchGame { path }) => match CartridgeLoader::load(&path) {
Some(SystemProfileAction::LaunchGame { target }) => {
match CartridgeLoader::load(&target.path) {
Ok(cartridge) => {
if cartridge.app_mode == AppMode::Game
&& let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task()
@ -68,29 +70,54 @@ impl HubHomeStep {
return Some(FirmwareState::ResumeResidentGame);
}
if let Err(error) = resident_game_termination::terminate_resident_game(
ctx.os,
ctx.platform,
false,
) {
ctx.os.log(
LogLevel::Warn,
LogLevel::Error,
LogSource::Hub,
0,
format!(
"Cannot launch Home game {} while resident game app_id {} is active",
cartridge.title, resident_app_id
"Failed to terminate resident game app_id {} before launching {}: {:?}",
resident_app_id, cartridge.title, error
),
);
return None;
}
}
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge)));
return Some(FirmwareState::LoadCartridge(
LoadCartridgeStep::new_game_switch(cartridge),
));
}
Err(error) => {
ctx.os.log(
LogLevel::Error,
LogSource::Hub,
0,
format!("Failed to launch Home game {}: {:?}", path.display(), error),
format!(
"Failed to launch Home game {}: {:?}",
target.path.display(),
error
),
);
}
},
}
}
Some(SystemProfileAction::KillResidentGame) => {
if let Err(error) =
resident_game_termination::terminate_resident_game(ctx.os, ctx.platform, true)
{
ctx.os.log(
LogLevel::Error,
LogSource::Hub,
0,
format!("Failed to 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,
@ -20,7 +23,7 @@ pub use services::foreground::{
};
pub use services::fs;
pub use services::game_library::{
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root,
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, GameSwitchTarget, discover_games_root,
};
pub use services::memcard::MemcardAsyncLaneOperation;
pub use services::process;

View File

@ -222,6 +222,28 @@ impl<'a> LifecycleFacade<'a> {
Ok(())
}
pub fn terminate_resident_game(&mut self) -> Result<Option<TaskId>, LifecycleError> {
let Some(task_id) = self.os.foreground_stack.resident_game_task() else {
return Ok(None);
};
let process_id = process_id_for_task(self.os, task_id)?;
self.os.task_manager.close_task(task_id);
self.os.process_manager.mark_stopped(process_id);
self.os.foreground_stack.clear_resident_game(task_id);
self.os.vm_sessions.remove_for_task(task_id);
self.os.game_lifecycle_events.retain(|event| event.task_id != task_id);
if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Game(owner) if owner == task_id)
{
self.os.foreground_stack.return_to_hub();
self.os.task_manager.clear_foreground();
}
Ok(Some(task_id))
}
pub fn crash_task(
&mut self,
task_id: TaskId,

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

@ -218,6 +218,42 @@ mod tests {
);
}
#[test]
fn terminate_resident_game_closes_task_and_removes_vm_session() {
let mut os = SystemOS::new(None);
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
os.lifecycle().request_home_from_game(game).expect("home request should succeed");
assert_eq!(os.lifecycle().pending_game_lifecycle_events().len(), 1);
assert_eq!(os.lifecycle().terminate_resident_game(), Ok(Some(game)));
assert_eq!(os.lifecycle().resident_game(), None);
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed));
assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped);
assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None);
assert!(os.lifecycle().pending_game_lifecycle_events().is_empty());
}
#[test]
fn terminate_resident_game_preserves_foreground_shell() {
let mut os = SystemOS::new(None);
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
os.lifecycle().request_home_from_game(game).expect("home request should succeed");
os.lifecycle().advance_game_pause_budget(game).expect("budget should expire");
let shell = os.sessions().create_vm_shell_task(7, "Settings");
assert_eq!(os.lifecycle().terminate_resident_game(), Ok(Some(game)));
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell));
assert_eq!(os.lifecycle().resident_game(), None);
assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed));
assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped);
assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None);
assert_eq!(os.lifecycle().task_state(shell), Some(TaskState::Foreground));
}
#[test]
fn vm_game_task_creates_vm_session() {
let mut os = SystemOS::new(None);

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,12 +1,12 @@
use crate::task::TaskId;
use crate::{CrashReport, GameLibrary, SystemOS};
use crate::{CrashReport, GameLibrary, GameSwitchTarget, SystemOS};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::color::Color;
use prometeu_hal::primitives::Rect;
use prometeu_hal::{
FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform,
ShellUiFramePacket,
};
use std::path::PathBuf;
const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 };
const SHELL_B_BUTTON: Rect = Rect { x: 256, y: 172, w: 112, h: 32 };
@ -20,6 +20,7 @@ const GAME_ROW_W: i32 = 336;
const GAME_ROW_H: i32 = 22;
const GAME_ROW_GAP: i32 = 6;
const MAX_HOME_GAME_ROWS: usize = 3;
pub const HUB_SUSPENDED_GAME_KILL_CONTROL: Rect = Rect { x: 288, y: 230, w: 168, h: 24 };
#[cfg(test)]
const SHELL_FRAME: Rect = Rect { x: 40, y: 20, w: 400, h: 220 };
@ -32,6 +33,8 @@ const COLOR_TEXT: Color = Color::rgb(230, 236, 224);
const COLOR_MUTED: Color = Color::rgb(91, 112, 128);
const COLOR_BUTTON: Color = Color::rgb(45, 59, 82);
const COLOR_BUTTON_ACTIVE: Color = Color::rgb(72, 91, 122);
const COLOR_KILL: Color = Color::rgb(164, 32, 40);
const COLOR_KILL_ACTIVE: Color = Color::rgb(206, 46, 54);
/// PrometeuHub: Launcher and system UI environment.
pub struct PrometeuHub {
@ -83,7 +86,8 @@ impl NativeShellApp {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemProfileAction {
LaunchNativeShell(NativeShellApp),
LaunchGame { path: PathBuf },
LaunchGame { target: GameSwitchTarget },
KillResidentGame,
CloseShell,
}
@ -132,12 +136,14 @@ impl PrometeuHub {
}
let input = platform.input();
let suspended_game = if in_shell { None } else { resident_game_indicator(os) };
if let Some(action) = action_for_click(
in_shell,
input.touch().x(),
input.touch().y(),
input.touch().f().pressed,
self.game_library.entries(),
suspended_game.as_ref(),
) {
return Some(action);
}
@ -150,7 +156,13 @@ impl PrometeuHub {
let pointer_y = platform.input().touch().y();
let packet = if os.windows().window_count() == 0 {
render_home_packet(pointer_x, pointer_y, self.game_library.entries())
let suspended_game = resident_game_indicator(os);
render_home_packet(
pointer_x,
pointer_y,
self.game_library.entries(),
suspended_game.as_ref(),
)
} else {
let mut commands = Vec::new();
for window in os.windows().windows() {
@ -233,6 +245,7 @@ fn action_for_click(
y: i32,
pressed: bool,
game_entries: &[crate::GameLibraryEntry],
suspended_game: Option<&SuspendedGameIndicator>,
) -> Option<SystemProfileAction> {
if !pressed {
return None;
@ -242,6 +255,10 @@ fn action_for_click(
return rect_contains(CLOSE_BUTTON, x, y).then_some(SystemProfileAction::CloseShell);
}
if suspended_game.is_some() && rect_contains(HUB_SUSPENDED_GAME_KILL_CONTROL, x, y) {
return Some(SystemProfileAction::KillResidentGame);
}
if let Some(entry) = game_entries
.iter()
.take(MAX_HOME_GAME_ROWS)
@ -249,7 +266,7 @@ fn action_for_click(
.find(|(index, _entry)| rect_contains(game_row_rect(*index), x, y))
.map(|(_index, entry)| entry)
{
return Some(SystemProfileAction::LaunchGame { path: entry.path.clone() });
return Some(SystemProfileAction::LaunchGame { target: entry.switch_target() });
}
HOME_BUTTONS
@ -258,6 +275,23 @@ fn action_for_click(
.map(|button| SystemProfileAction::LaunchNativeShell(button.app))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SuspendedGameIndicator {
title: String,
}
fn resident_game_indicator(os: &mut SystemOS) -> Option<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,
@ -282,6 +316,7 @@ fn render_home_packet(
pointer_x: i32,
pointer_y: i32,
game_entries: &[crate::GameLibraryEntry],
suspended_game: Option<&SuspendedGameIndicator>,
) -> ShellUiFramePacket {
let mut commands = Vec::new();
commands.push(GfxUiCommand::Clear { color: COLOR_BG });
@ -321,6 +356,11 @@ fn render_home_packet(
draw_button(&mut commands, button.rect, button.label, hovered);
}
if let Some(suspended_game) = suspended_game {
let hovered = rect_contains(HUB_SUSPENDED_GAME_KILL_CONTROL, pointer_x, pointer_y);
draw_suspended_game_kill_control(&mut commands, suspended_game, hovered);
}
ShellUiFramePacket::new(commands)
}
@ -423,6 +463,39 @@ fn draw_game_row(commands: &mut Vec<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 });
}
@ -434,6 +507,7 @@ mod tests {
use crate::windows::WindowOwner;
use prometeu_drivers::TestPlatform;
use prometeu_hal::{InputSignals, RuntimePlatform};
use std::path::PathBuf;
fn focused_native_shell_fixture(
signals: &InputSignals,
@ -462,7 +536,14 @@ mod tests {
#[test]
fn shell_a_button_click_emits_launch_action() {
assert_eq!(
action_for_click(false, HOME_BUTTONS[0].rect.x, HOME_BUTTONS[0].rect.y, true, &[]),
action_for_click(
false,
HOME_BUTTONS[0].rect.x,
HOME_BUTTONS[0].rect.y,
true,
&[],
None
),
Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellA))
);
}
@ -475,7 +556,8 @@ mod tests {
SHELL_B_BUTTON.x + SHELL_B_BUTTON.w - 1,
SHELL_B_BUTTON.y,
true,
&[]
&[],
None
),
Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellB))
);
@ -489,7 +571,8 @@ mod tests {
SHELL_A_BUTTON.x + SHELL_A_BUTTON.w,
SHELL_A_BUTTON.y,
true,
&[]
&[],
None
),
None
);
@ -499,7 +582,8 @@ mod tests {
SHELL_A_BUTTON.x,
SHELL_A_BUTTON.y + SHELL_A_BUTTON.h,
true,
&[]
&[],
None
),
None
);
@ -507,7 +591,10 @@ mod tests {
#[test]
fn held_pointer_does_not_emit_click_action() {
assert_eq!(action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[]), None);
assert_eq!(
action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[], None),
None
);
}
#[test]
@ -535,19 +622,35 @@ mod tests {
discovered_at: std::time::SystemTime::UNIX_EPOCH,
};
let action = action_for_click(
false,
GAME_ROW_X,
GAME_ROW_Y,
true,
std::slice::from_ref(&entry),
None,
);
assert_eq!(
action_for_click(false, GAME_ROW_X, GAME_ROW_Y, true, std::slice::from_ref(&entry)),
Some(SystemProfileAction::LaunchGame { path: entry.path.clone() })
action,
Some(SystemProfileAction::LaunchGame {
target: GameSwitchTarget {
path: entry.path.clone(),
app_id: 42,
title: "Stress".to_string(),
app_version: "1.0.0".to_string(),
}
})
);
}
#[test]
fn close_button_click_emits_close_only_in_shell() {
assert_eq!(
action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]),
action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[], None),
Some(SystemProfileAction::CloseShell)
);
assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), None);
assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[], None), None);
}
#[test]
@ -561,7 +664,7 @@ mod tests {
#[test]
fn home_render_produces_shell_ui_packet_commands() {
let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[]);
let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[], None);
assert!(matches!(packet.commands.first(), Some(GfxUiCommand::Clear { .. })));
assert!(packet.commands.iter().any(|command| {
@ -569,6 +672,123 @@ mod tests {
}));
}
#[test]
fn resident_game_indicator_uses_only_resident_game_sessions() {
let mut os = SystemOS::new(None);
assert_eq!(resident_game_indicator(&mut os), None);
let shell = os.sessions().create_vm_shell_task(7, "VM Shell");
assert_eq!(resident_game_indicator(&mut os), None);
os.lifecycle().suspend_task(shell).expect("shell suspend should succeed");
assert_eq!(resident_game_indicator(&mut os), None);
let game = os.sessions().create_vm_game_task(42, "Stress Console");
os.lifecycle().suspend_task(game).expect("game suspend should succeed");
assert_eq!(
resident_game_indicator(&mut os),
Some(SuspendedGameIndicator { title: "Stress Console".to_string() })
);
}
#[test]
fn home_render_shows_suspended_game_kill_control_when_game_is_resident() {
let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() };
let packet = render_home_packet(
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
&[],
Some(&suspended_game),
);
assert!(packet.commands.iter().any(|command| {
matches!(
command,
GfxUiCommand::FillRect { rect, color }
if *rect == HUB_SUSPENDED_GAME_KILL_CONTROL && *color == COLOR_KILL_ACTIVE
)
}));
assert!(packet.commands.iter().any(|command| {
matches!(
command,
GfxUiCommand::DrawText { x, y, text, .. }
if *x == HUB_SUSPENDED_GAME_KILL_CONTROL.x + 8
&& *y == HUB_SUSPENDED_GAME_KILL_CONTROL.y + 8
&& text == "Stress Console"
)
}));
}
#[test]
fn home_render_omits_suspended_game_kill_control_without_resident_game() {
let packet = render_home_packet(
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
&[],
None,
);
assert!(!packet.commands.iter().any(|command| {
matches!(
command,
GfxUiCommand::FillRect { rect, color }
if *rect == HUB_SUSPENDED_GAME_KILL_CONTROL
&& (*color == COLOR_KILL || *color == COLOR_KILL_ACTIVE)
)
}));
}
#[test]
fn suspended_game_kill_control_click_emits_kill_action() {
let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() };
assert_eq!(
action_for_click(
false,
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
true,
&[],
Some(&suspended_game),
),
Some(SystemProfileAction::KillResidentGame)
);
assert_eq!(
action_for_click(
false,
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
true,
&[],
None,
),
None
);
}
#[test]
fn suspended_game_kill_control_truncates_long_titles() {
let long_title =
SuspendedGameIndicator { title: "Extremely Long Resident Game Title".to_string() };
let packet = render_home_packet(0, 0, &[], Some(&long_title));
let label = packet
.commands
.iter()
.find_map(|command| match command {
GfxUiCommand::DrawText { x, y, text, .. }
if *x == HUB_SUSPENDED_GAME_KILL_CONTROL.x + 8
&& *y == HUB_SUSPENDED_GAME_KILL_CONTROL.y + 8 =>
{
Some(text)
}
_ => None,
})
.expect("kill control text should render");
assert!(label.ends_with(".."));
assert!(label.chars().count() <= ((HUB_SUSPENDED_GAME_KILL_CONTROL.w - 16) / 8) as usize);
}
#[test]
fn shell_close_button_sits_inside_shell_frame() {
assert!(rect_contains(SHELL_FRAME, CLOSE_BUTTON.x, CLOSE_BUTTON.y));

View File

@ -25,6 +25,29 @@ impl GameLibraryEntry {
discovered_at,
}
}
pub fn switch_target(&self) -> GameSwitchTarget {
GameSwitchTarget::from_entry(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GameSwitchTarget {
pub path: PathBuf,
pub app_id: u32,
pub title: String,
pub app_version: String,
}
impl GameSwitchTarget {
pub fn from_entry(entry: &GameLibraryEntry) -> Self {
Self {
path: entry.path.clone(),
app_id: entry.app_id,
title: entry.title.clone(),
app_version: entry.app_version.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -256,6 +279,31 @@ mod tests {
}));
}
#[test]
fn switch_target_preserves_resolved_identity() {
let path = PathBuf::from("test-cartridges/stress-console");
let entry = GameLibraryEntry::new(
path.clone(),
CartridgeManifest {
magic: "PMTU".to_string(),
cartridge_version: 1,
app_id: 42,
title: "Stress".to_string(),
app_version: "1.0.0".to_string(),
app_mode: AppMode::Game,
capabilities: vec![],
},
UNIX_EPOCH,
);
let target = entry.switch_target();
assert_eq!(target.path, path);
assert_eq!(target.app_id, 42);
assert_eq!(target.title, "Stress");
assert_eq!(target.app_version, "1.0.0");
}
#[test]
fn missing_root_returns_diagnostic_and_no_entries() {
let root = TestRoot::new();
@ -267,4 +315,22 @@ mod tests {
assert_eq!(library.diagnostics().len(), 1);
assert_eq!(library.diagnostics()[0].reason, GameLibraryDiagnosticReason::RootNotDirectory);
}
#[test]
fn repository_test_cartridges_include_stress_and_dummy_boy() {
let games_root =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").join("test-cartridges");
let library = discover_games_root(games_root);
let entries = library.entries();
assert!(entries.iter().any(|entry| {
entry.app_id == 1
&& entry.title == "Stress Console"
&& entry.path.ends_with("stress-console")
}));
assert!(entries.iter().any(|entry| {
entry.app_id == 2 && entry.title == "Dummy Boy" && entry.path.ends_with("dummy-boy")
}));
}
}

View File

@ -121,6 +121,10 @@ impl VmSessionRegistry {
self.get_mut(VmSessionId(task_id))
}
pub fn remove_for_task(&mut self, task_id: TaskId) -> Option<VmSession> {
self.sessions.remove(&VmSessionId(task_id))
}
pub fn resident_game(&self) -> Option<&VmSession> {
self.sessions.values().find(|session| session.app_mode == AppMode::Game)
}
@ -231,6 +235,31 @@ mod tests {
assert_eq!(registry.resident_game_for_app(43).map(|session| session.task_id), None);
}
#[test]
fn remove_for_task_removes_only_matching_session() {
let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);
let game_task = Task::new(TaskId(7), game_process.id, 42, "Stress", TaskKind::Game);
let shell_process = Process::new(ProcessId(2), 9, "Shell", ProcessKind::VmShell);
let shell_task = Task::new(TaskId(8), shell_process.id, 9, "Shell", TaskKind::Shell);
let mut registry = VmSessionRegistry::new();
registry
.create_for_task(&game_task, &game_process, "", None, Arc::new(AtomicU32::new(0)))
.expect("game session should create");
registry
.create_for_task(&shell_task, &shell_process, "", None, Arc::new(AtomicU32::new(0)))
.expect("shell session should create");
let removed = registry.remove_for_task(game_task.id).expect("game session should remove");
assert_eq!(removed.task_id, game_task.id);
assert_eq!(registry.for_task(game_task.id).map(|session| session.task_id), None);
assert_eq!(
registry.for_task(shell_task.id).map(|session| session.task_id),
Some(shell_task.id)
);
}
#[test]
fn session_runtime_state_is_independent_per_session() {
let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);

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":39,"PLN":157,"LSN":53,"CLSN":1}}
{"type":"discussion","id":"DSC-0043","status":"open","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-03","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"meta","next_id":{"DSC":45,"AGD":48,"DEC":41,"PLN":167,"LSN":55,"CLSN":1}}
{"type":"discussion","id":"DSC-0044","status":"done","ticket":"hub-suspended-game-kill-affordance","title":"Hub Suspended Game Kill Affordance","created_at":"2026-07-05","updated_at":"2026-07-05","tags":["hub","lifecycle","game","ui"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0054","file":"discussion/lessons/DSC-0044-hub-suspended-game-kill-affordance/LSN-0054-manual-hub-kill-must-share-game-termination-cleanup.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]}
{"type":"discussion","id":"DSC-0043","status":"done","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0053","file":"discussion/lessons/DSC-0043-system-os-cartridge-switch-orchestrator/LSN-0053-game-switching-is-lifecycle-replacement-not-loader-work.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]}
{"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}
{"type":"discussion","id":"DSC-0040","status":"done","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-06","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0048","file":"discussion/lessons/DSC-0040-vm-render-parallel-execution-boundary/LSN-0048-render-workers-need-a-closed-packet-contract.md","status":"done","created_at":"2026-06-06","updated_at":"2026-06-06"}]}
{"type":"discussion","id":"DSC-0041","status":"done","ticket":"foreground-stack-game-pause-shell-vm-backed","title":"Foreground Stack, Game Pause, and VM-Backed Shell Coexistence","created_at":"2026-06-05","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","shell","game","vm","foreground","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0052","file":"discussion/lessons/DSC-0041-foreground-stack-game-pause-shell-vm-backed/LSN-0052-foreground-ownership-and-vm-session-ownership-are-separate.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]}

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,145 +0,0 @@
---
id: AGD-0044
ticket: system-os-cartridge-switch-orchestrator
title: SystemOS Cartridge Switch Orchestrator
status: open
created: 2026-07-03
resolved:
decision:
tags: [runtime, os, lifecycle, game, cartridge, architecture]
---
## Contexto
A `AGD-0041` cobre o caso `Game -> Home/Shell -> mesmo Game`: o usuario sai do
foreground do jogo, o SO assume a tela, Shell/Hub pode rodar, e depois o mesmo
Game e retomado.
Existe um segundo caso, diferente, que nao deve ser escondido dentro da
`AGD-0041`: `Game A -> Home -> Game B`.
Nesse fluxo, o usuario esta com um Game rodando, aperta Home, volta para o SO e
pede para iniciar outro jogo. O SystemOS nao deve simplesmente chamar um loader
por cima do estado atual. Ele precisa orquestrar a substituicao do app corrente:
fechar ou descartar o Game residente, limpar estado cartridge-scoped, resolver o
novo alvo, carregar o proximo cartucho e entrar no novo estado de firmware.
Essa agenda tambem se relaciona com a `AGD-0003 / system-run-cart`: `run_cart`,
se existir, nao e userland e nao e o orquestrador. O carregador de cartucho e
apenas uma etapa dentro de uma transicao comandada pelo SystemOS.
## Problema
Sem um orquestrador explicito de troca de cartucho no SystemOS, a plataforma
pode cair em estados incoerentes:
- carregar um novo Game sem fechar corretamente o Game anterior;
- reaproveitar estado VM, assets, input, audio, render ou telemetria do cartucho
anterior;
- misturar boot direto por linha de comando com troca de app sob o SO;
- deixar o loader decidir lifecycle, quando ele deveria apenas materializar o
cartucho alvo;
- tentar implementar `Home -> outro Game` antes de existir o contrato de
foreground/pausa definido pela `AGD-0041`.
## Pontos Criticos
- Esta agenda deve ser executada depois da `AGD-0041`.
- `Game -> Home -> mesmo Game` fica fora desta agenda.
- O SystemOS deve ser a autoridade da troca entre cartuchos sob o SO.
- O loader nao deve possuir politica de fechamento, pausa, foreground ou
cleanup.
- Boot direto por CLI/debug continua sendo caminho separado e mais simples.
- A troca para outro Game deve tratar falha de load sem deixar o sistema em
estado parcialmente substituido.
- `.pmc`, catalogo completo e UX final de launcher continuam fora de escopo,
salvo se forem necessarios para definir o contrato minimo.
## Opcoes
### Opcao A - Adiar tudo para depois da AGD-0041
- **Abordagem:** manter esta agenda como registro de escopo futuro; primeiro
fechar e implementar o contrato `Game -> Home -> mesmo Game`.
- **Pro:** evita misturar pausa/resume com substituicao de cartucho; reduz risco
de reabrir arquitetura durante a `AGD-0041`.
- **Contra:** a plataforma continua sem troca `Home -> outro Game` ate uma etapa
posterior.
- **Manutenibilidade:** alta, porque cada agenda fecha um eixo de lifecycle.
### Opcao B - Definir agora o contrato conceitual, implementar depois
- **Abordagem:** fechar uma decisao curta ja dizendo que o orquestrador pertence
ao SystemOS, que `run_cart` nao e userland, e que a execucao depende da
`AGD-0041`; o plano de codigo fica para depois.
- **Pro:** reduz ambiguidade enquanto trabalhamos na `AGD-0041`.
- **Contra:** pode produzir uma decisao com pouca evidencia operacional antes
do contrato de foreground estar fechado.
- **Manutenibilidade:** boa se a decisao for claramente marcada como dependente
da `AGD-0041`.
### Opcao C - Implementar o orquestrador completo agora
- **Abordagem:** tentar resolver `Game A -> Home -> Game B` antes ou junto da
`AGD-0041`.
- **Pro:** ataca o fluxo final diretamente.
- **Contra:** mistura pausa/resume, fechamento, cleanup, resolucao de alvo e
falha de load em uma unica frente; alto risco de contrato instavel.
- **Manutenibilidade:** baixa para v1, porque a troca completa depende do
contrato de foreground ainda aberto.
## Sugestao / Recomendacao
Recomendo a Opcao A agora, com esta agenda aberta como marcador explicito de
escopo futuro.
A ordem preferida passa a ser:
1. fechar a `AGD-0041` para definir foreground, pausa/suspensao, Shell no
foreground e retorno ao mesmo Game;
2. voltar para esta agenda e decidir o contrato do orquestrador de troca de
cartucho no SystemOS;
3. so entao planejar a execucao de `Game A -> Home -> Game B`.
Se durante a `AGD-0041` aparecer uma decisao que exige antecipar parte do
orquestrador, esta agenda deve ser reaberta/atualizada com a dependencia
concreta em vez de esconder a troca de cartucho dentro da agenda de foreground.
## Perguntas em Aberto
- [ ] Depois da `AGD-0041`, qual estado representa um Game que sera descartado
em vez de retomado?
- [ ] O fechamento do Game atual e observavel para o firmware/game, ou e uma
destruicao interna do SystemOS?
- [ ] Quais estados cartridge-scoped devem ser limpos antes do novo load: VM,
assets, FS handles, input, audio, render ownership, telemetry, logs?
- [ ] Se o novo cartucho falha ao carregar, o sistema volta ao Home, mostra uma
tela de erro, ou tenta preservar o Game anterior?
- [ ] Como a resolucao do novo alvo e representada sem depender de catalogo
completo ou `.pmc`?
- [ ] Qual evidencia minima prova `Game A -> Home -> Game B` sem depender de UX
final de launcher?
## Criterio para Encerrar
A agenda pode virar decisao quando:
- a `AGD-0041` tiver fechado o contrato de foreground/pausa/resume;
- estiver claro que o orquestrador pertence ao SystemOS;
- `run_cart`/loader estiver limitado a etapa interna, nao userland;
- existir regra para fechar/descartar o Game atual;
- existir contrato de limpeza de estado cartridge-scoped;
- existir politica para falha de carregamento do novo Game;
- houver criterio de teste minimo para `Game A -> Home -> Game B`.
## Discussao
- 2026-07-03: Agenda criada a partir da separacao entre `AGD-0041`
(`Game -> Home -> mesmo Game`) e `AGD-0003` (`run_cart`/loader nao userland).
O novo escopo cobre a troca posterior `Game A -> Home -> Game B`, com
orquestracao pelo SystemOS depois que o contrato de foreground estiver
fechado.
## Resolution
Ainda em aberto.

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.