Compare commits
10 Commits
b30ff73e3d
...
de53eb5ad7
| Author | SHA1 | Date | |
|---|---|---|---|
| de53eb5ad7 | |||
| 49cb16b6cb | |||
| 3aec829c70 | |||
| 73c8246c8d | |||
| fa0a768b84 | |||
| 62573e9574 | |||
| c814e057dc | |||
| 8d719843c7 | |||
| d6afa6ae6b | |||
| 3df8227ebf |
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -1469,6 +1469,16 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbxgen-dummy-boy"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prometeu-bytecode",
|
||||||
|
"prometeu-hal",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pbxgen-stress"
|
name = "pbxgen-stress"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ members = [
|
|||||||
"crates/console/prometeu-vm",
|
"crates/console/prometeu-vm",
|
||||||
"crates/host/prometeu-host-desktop-winit",
|
"crates/host/prometeu-host-desktop-winit",
|
||||||
"crates/tools/prometeu-cli",
|
"crates/tools/prometeu-cli",
|
||||||
|
"crates/tools/pbxgen-dummy-boy",
|
||||||
"crates/tools/pbxgen-stress",
|
"crates/tools/pbxgen-stress",
|
||||||
"crates/dev/prometeu-test-support",
|
"crates/dev/prometeu-test-support",
|
||||||
"crates/dev/prometeu-layer-tests",
|
"crates/dev/prometeu-layer-tests",
|
||||||
|
|||||||
@ -1858,6 +1858,9 @@ impl AssetManager {
|
|||||||
self.glyph_slot_index.clear();
|
self.glyph_slot_index.clear();
|
||||||
self.sound_slots.write().unwrap().fill(None);
|
self.sound_slots.write().unwrap().fill(None);
|
||||||
self.scene_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 {
|
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));
|
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]
|
#[test]
|
||||||
fn test_load_returns_asset_not_found() {
|
fn test_load_returns_asset_not_found() {
|
||||||
let banks = Arc::new(MemoryBanks::new());
|
let banks = Arc::new(MemoryBanks::new());
|
||||||
|
|||||||
@ -24,6 +24,8 @@ pub trait GlyphBankPoolAccess: Send + Sync {
|
|||||||
pub trait GlyphBankPoolInstaller: Send + Sync {
|
pub trait GlyphBankPoolInstaller: Send + Sync {
|
||||||
/// Atomically swaps the resident GlyphBank in the specified slot.
|
/// Atomically swaps the resident GlyphBank in the specified slot.
|
||||||
fn install_glyph_bank(&self, slot: usize, bank: Arc<GlyphBank>);
|
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.
|
/// Non-generic interface for peripherals to access sound banks.
|
||||||
@ -38,6 +40,8 @@ pub trait SoundBankPoolAccess: Send + Sync {
|
|||||||
pub trait SoundBankPoolInstaller: Send + Sync {
|
pub trait SoundBankPoolInstaller: Send + Sync {
|
||||||
/// Atomically swaps the resident SoundBank in the specified slot.
|
/// Atomically swaps the resident SoundBank in the specified slot.
|
||||||
fn install_sound_bank(&self, slot: usize, bank: Arc<SoundBank>);
|
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.
|
/// Non-generic interface for peripherals to access scene banks.
|
||||||
@ -52,6 +56,8 @@ pub trait SceneBankPoolAccess: Send + Sync {
|
|||||||
pub trait SceneBankPoolInstaller: Send + Sync {
|
pub trait SceneBankPoolInstaller: Send + Sync {
|
||||||
/// Atomically swaps the resident SceneBank in the specified slot.
|
/// Atomically swaps the resident SceneBank in the specified slot.
|
||||||
fn install_scene_bank(&self, slot: usize, bank: Arc<SceneBank>);
|
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.
|
/// Centralized container for all hardware memory banks.
|
||||||
@ -100,6 +106,10 @@ impl GlyphBankPoolInstaller for MemoryBanks {
|
|||||||
pool[slot] = Some(bank);
|
pool[slot] = Some(bank);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_glyph_banks(&self) {
|
||||||
|
self.glyph_bank_pool.write().unwrap().fill(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoundBankPoolAccess for MemoryBanks {
|
impl SoundBankPoolAccess for MemoryBanks {
|
||||||
@ -120,6 +130,10 @@ impl SoundBankPoolInstaller for MemoryBanks {
|
|||||||
pool[slot] = Some(bank);
|
pool[slot] = Some(bank);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_sound_banks(&self) {
|
||||||
|
self.sound_bank_pool.write().unwrap().fill(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SceneBankPoolAccess for MemoryBanks {
|
impl SceneBankPoolAccess for MemoryBanks {
|
||||||
@ -140,6 +154,10 @@ impl SceneBankPoolInstaller for MemoryBanks {
|
|||||||
pool[slot] = Some(bank);
|
pool[slot] = Some(bank);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_scene_banks(&self) {
|
||||||
|
self.scene_bank_pool.write().unwrap().fill(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HalRenderResourceAccess for MemoryBanks {
|
impl HalRenderResourceAccess for MemoryBanks {
|
||||||
|
|||||||
@ -250,21 +250,24 @@ impl Firmware {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::firmware::firmware_state::HubHomeStep;
|
use crate::firmware::firmware_state::HubHomeStep;
|
||||||
|
use crate::firmware::resident_game_termination::{self, ResidentGameTerminationOutcome};
|
||||||
use prometeu_bytecode::assembler::assemble;
|
use prometeu_bytecode::assembler::assemble;
|
||||||
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
|
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
|
||||||
use prometeu_drivers::TestPlatform;
|
use prometeu_drivers::{GlyphBankPoolAccess, SceneBankPoolAccess, TestPlatform};
|
||||||
use prometeu_hal::RuntimePlatform;
|
use prometeu_hal::RuntimePlatform;
|
||||||
use prometeu_hal::app_mode::AppMode;
|
use prometeu_hal::app_mode::AppMode;
|
||||||
|
use prometeu_hal::asset::{BankType, SlotRef};
|
||||||
use prometeu_hal::cartridge::AssetsPayloadSource;
|
use prometeu_hal::cartridge::AssetsPayloadSource;
|
||||||
use prometeu_hal::color::Color;
|
use prometeu_hal::color::Color;
|
||||||
|
use prometeu_hal::log::{LogLevel, LogSource};
|
||||||
use prometeu_hal::primitives::Rect;
|
use prometeu_hal::primitives::Rect;
|
||||||
use prometeu_hal::syscalls::{Syscall, caps};
|
use prometeu_hal::syscalls::{Syscall, caps};
|
||||||
use prometeu_system::process::ProcessState;
|
use prometeu_system::process::ProcessState;
|
||||||
use prometeu_system::task::{TaskId, TaskState};
|
use prometeu_system::task::{TaskId, TaskState};
|
||||||
use prometeu_system::windows::WindowOwner;
|
use prometeu_system::windows::WindowOwner;
|
||||||
use prometeu_system::{
|
use prometeu_system::{
|
||||||
CrashReport, ForegroundOwner, GameLifecycleEventKind, ResidentGameState,
|
CrashReport, ForegroundOwner, GameLifecycleEventKind, HUB_SUSPENDED_GAME_KILL_CONTROL,
|
||||||
discover_games_root,
|
ResidentGameState, discover_games_root,
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -299,6 +302,18 @@ mod tests {
|
|||||||
title: &str,
|
title: &str,
|
||||||
app_version: &str,
|
app_version: &str,
|
||||||
with_program: bool,
|
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 {
|
) -> PathBuf {
|
||||||
let cart = self.path.join(name);
|
let cart = self.path.join(name);
|
||||||
fs::create_dir_all(&cart).expect("test cartridge should be created");
|
fs::create_dir_all(&cart).expect("test cartridge should be created");
|
||||||
@ -314,8 +329,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
fs::write(cart.join("manifest.json"), manifest)
|
fs::write(cart.join("manifest.json"), manifest)
|
||||||
.expect("test manifest should be written");
|
.expect("test manifest should be written");
|
||||||
if with_program {
|
if let Some(program) = program {
|
||||||
fs::write(cart.join("program.pbx"), halting_program())
|
fs::write(cart.join("program.pbx"), program)
|
||||||
.expect("test program should be written");
|
.expect("test program should be written");
|
||||||
}
|
}
|
||||||
cart
|
cart
|
||||||
@ -346,6 +361,10 @@ mod tests {
|
|||||||
.serialize()
|
.serialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_test_cartridges_root() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").join("test-cartridges")
|
||||||
|
}
|
||||||
|
|
||||||
fn invalid_game_cartridge() -> Cartridge {
|
fn invalid_game_cartridge() -> Cartridge {
|
||||||
Cartridge {
|
Cartridge {
|
||||||
app_id: 7,
|
app_id: 7,
|
||||||
@ -460,6 +479,12 @@ mod tests {
|
|||||||
windows.set_focus(id);
|
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]
|
#[test]
|
||||||
fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() {
|
fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() {
|
||||||
let mut firmware = Firmware::new(None);
|
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]
|
#[test]
|
||||||
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
|
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
|
||||||
let root = TestGameRoot::new();
|
let root = TestGameRoot::new();
|
||||||
@ -989,6 +1441,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let tick_index_before_home = firmware.os.vm().tick_index();
|
let tick_index_before_home = firmware.os.vm().tick_index();
|
||||||
let render_ownership_before_home = firmware.os.vm().active_render_ownership();
|
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();
|
firmware.request_home_from_host();
|
||||||
|
|
||||||
@ -1020,7 +1474,6 @@ mod tests {
|
|||||||
let render_ownership_after_home = firmware.os.vm().active_render_ownership();
|
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_mode, AppMode::Shell);
|
||||||
assert_eq!(render_ownership_after_home.app_id, 0);
|
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!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
|
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
|
||||||
@ -1049,6 +1502,8 @@ mod tests {
|
|||||||
firmware.request_home_from_host();
|
firmware.request_home_from_host();
|
||||||
firmware.tick(&signals, &mut platform);
|
firmware.tick(&signals, &mut platform);
|
||||||
let hub_ownership = firmware.os.vm().active_render_ownership();
|
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!(firmware.resume_resident_game_from_home());
|
||||||
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
||||||
@ -1061,7 +1516,6 @@ mod tests {
|
|||||||
let game_ownership = firmware.os.vm().active_render_ownership();
|
let game_ownership = firmware.os.vm().active_render_ownership();
|
||||||
assert_eq!(game_ownership.app_mode, AppMode::Game);
|
assert_eq!(game_ownership.app_mode, AppMode::Game);
|
||||||
assert_eq!(game_ownership.app_id, 9);
|
assert_eq!(game_ownership.app_id, 9);
|
||||||
assert!(game_ownership.epoch > hub_ownership.epoch);
|
|
||||||
|
|
||||||
let held_signals =
|
let held_signals =
|
||||||
InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals };
|
InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals };
|
||||||
|
|||||||
@ -2,6 +2,7 @@ use crate::firmware::firmware_state::{
|
|||||||
AppCrashesStep, FirmwareState, LoadCartridgeStep, ShellRunningStep,
|
AppCrashesStep, FirmwareState, LoadCartridgeStep, ShellRunningStep,
|
||||||
};
|
};
|
||||||
use crate::firmware::prometeu_context::PrometeuContext;
|
use crate::firmware::prometeu_context::PrometeuContext;
|
||||||
|
use crate::firmware::resident_game_termination;
|
||||||
use prometeu_hal::app_mode::AppMode;
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::cartridge_loader::CartridgeLoader;
|
use prometeu_hal::cartridge_loader::CartridgeLoader;
|
||||||
use prometeu_hal::log::{LogLevel, LogSource};
|
use prometeu_hal::log::{LogLevel, LogSource};
|
||||||
@ -69,7 +70,11 @@ impl HubHomeStep {
|
|||||||
return Some(FirmwareState::ResumeResidentGame);
|
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(
|
ctx.os.log(
|
||||||
LogLevel::Error,
|
LogLevel::Error,
|
||||||
LogSource::Hub,
|
LogSource::Hub,
|
||||||
@ -83,9 +88,9 @@ impl HubHomeStep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(
|
return Some(FirmwareState::LoadCartridge(
|
||||||
cartridge,
|
LoadCartridgeStep::new_game_switch(cartridge),
|
||||||
)));
|
));
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
ctx.os.log(
|
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 => {}
|
Some(SystemProfileAction::CloseShell) | None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
use crate::firmware::firmware_state::{
|
use crate::firmware::firmware_state::{
|
||||||
AppCrashesStep, FirmwareState, GameRunningStep, ShellRunningStep,
|
AppCrashesStep, FirmwareState, GameRunningStep, HubHomeStep, ShellRunningStep,
|
||||||
};
|
};
|
||||||
use crate::firmware::prometeu_context::PrometeuContext;
|
use crate::firmware::prometeu_context::PrometeuContext;
|
||||||
|
use crate::firmware::resident_game_termination;
|
||||||
use prometeu_hal::app_mode::AppMode;
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::cartridge::Cartridge;
|
use prometeu_hal::cartridge::Cartridge;
|
||||||
use prometeu_hal::color::Color;
|
use prometeu_hal::color::Color;
|
||||||
@ -16,11 +17,28 @@ pub struct LoadCartridgeStep {
|
|||||||
init_error: Option<CrashReport>,
|
init_error: Option<CrashReport>,
|
||||||
loaded_task_id: Option<prometeu_system::task::TaskId>,
|
loaded_task_id: Option<prometeu_system::task::TaskId>,
|
||||||
loaded_app_mode: Option<AppMode>,
|
loaded_app_mode: Option<AppMode>,
|
||||||
|
failure_recovery: LoadCartridgeFailureRecovery,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum LoadCartridgeFailureRecovery {
|
||||||
|
AppCrash,
|
||||||
|
ReturnToHub,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoadCartridgeStep {
|
impl LoadCartridgeStep {
|
||||||
pub fn new(cartridge: Cartridge) -> Self {
|
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) {
|
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
|
||||||
@ -31,6 +49,8 @@ impl LoadCartridgeStep {
|
|||||||
format!("Loading cartridge: {}", self.cartridge.title),
|
format!("Loading cartridge: {}", self.cartridge.title),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ctx.platform.game2d_frame_composer().unbind_scene();
|
||||||
|
|
||||||
// Initialize Asset Manager
|
// Initialize Asset Manager
|
||||||
ctx.platform.assets_mut().initialize_for_cartridge(
|
ctx.platform.assets_mut().initialize_for_cartridge(
|
||||||
self.cartridge.asset_table.clone(),
|
self.cartridge.asset_table.clone(),
|
||||||
@ -51,6 +71,38 @@ impl LoadCartridgeStep {
|
|||||||
|
|
||||||
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
||||||
if let Some(report) = self.init_error.take() {
|
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 }));
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ pub(crate) mod firmware_step_reset;
|
|||||||
pub(crate) mod firmware_step_shell_running;
|
pub(crate) mod firmware_step_shell_running;
|
||||||
pub(crate) mod firmware_step_splash_screen;
|
pub(crate) mod firmware_step_splash_screen;
|
||||||
mod prometeu_context;
|
mod prometeu_context;
|
||||||
|
pub(crate) mod resident_game_termination;
|
||||||
|
|
||||||
pub use boot_target::BootTarget;
|
pub use boot_target::BootTarget;
|
||||||
pub use firmware::Firmware;
|
pub use firmware::Firmware;
|
||||||
|
|||||||
@ -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 })
|
||||||
|
}
|
||||||
@ -8,7 +8,10 @@ pub use os::{
|
|||||||
DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError,
|
DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError,
|
||||||
LifecycleOperation, SessionError, SystemOS,
|
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 prometeu_hal::{RenderWorkerBackend, RenderWorkerFrameSink};
|
||||||
pub use services::async_work::{
|
pub use services::async_work::{
|
||||||
AsyncWorkActiveJob, AsyncWorkCancelToken, AsyncWorkJobContext, AsyncWorkJobId,
|
AsyncWorkActiveJob, AsyncWorkCancelToken, AsyncWorkJobContext, AsyncWorkJobId,
|
||||||
|
|||||||
@ -250,6 +250,12 @@ impl<'a> VmFacade<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn active_render_ownership(&self) -> RenderOwnership {
|
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()
|
self.os.vm_runtime.render_manager.active_ownership()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,4 +323,8 @@ impl<'a> VmFacade<'a> {
|
|||||||
.foreground_task()
|
.foreground_task()
|
||||||
.or_else(|| self.os.foreground_stack.resident_game_task())
|
.or_else(|| self.os.foreground_stack.resident_game_task())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn foreground_session_task_id(&self) -> Option<TaskId> {
|
||||||
|
self.os.task_manager.foreground_task()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
mod prometeu_hub;
|
mod prometeu_hub;
|
||||||
|
|
||||||
pub use prometeu_hub::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
|
pub use prometeu_hub::{
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL, NativeShellApp, PrometeuHub, SystemProfileAction,
|
||||||
|
SystemProfileUpdate,
|
||||||
|
};
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
#[allow(clippy::module_inception)]
|
#[allow(clippy::module_inception)]
|
||||||
mod prometeu_hub;
|
mod prometeu_hub;
|
||||||
|
|
||||||
pub use prometeu_hub::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
|
pub use prometeu_hub::{
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL, NativeShellApp, PrometeuHub, SystemProfileAction,
|
||||||
|
SystemProfileUpdate,
|
||||||
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use crate::task::TaskId;
|
use crate::task::TaskId;
|
||||||
use crate::{CrashReport, GameLibrary, GameSwitchTarget, SystemOS};
|
use crate::{CrashReport, GameLibrary, GameSwitchTarget, SystemOS};
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::color::Color;
|
use prometeu_hal::color::Color;
|
||||||
use prometeu_hal::primitives::Rect;
|
use prometeu_hal::primitives::Rect;
|
||||||
use prometeu_hal::{
|
use prometeu_hal::{
|
||||||
@ -19,6 +20,7 @@ const GAME_ROW_W: i32 = 336;
|
|||||||
const GAME_ROW_H: i32 = 22;
|
const GAME_ROW_H: i32 = 22;
|
||||||
const GAME_ROW_GAP: i32 = 6;
|
const GAME_ROW_GAP: i32 = 6;
|
||||||
const MAX_HOME_GAME_ROWS: usize = 3;
|
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)]
|
#[cfg(test)]
|
||||||
const SHELL_FRAME: Rect = Rect { x: 40, y: 20, w: 400, h: 220 };
|
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_MUTED: Color = Color::rgb(91, 112, 128);
|
||||||
const COLOR_BUTTON: Color = Color::rgb(45, 59, 82);
|
const COLOR_BUTTON: Color = Color::rgb(45, 59, 82);
|
||||||
const COLOR_BUTTON_ACTIVE: Color = Color::rgb(72, 91, 122);
|
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.
|
/// PrometeuHub: Launcher and system UI environment.
|
||||||
pub struct PrometeuHub {
|
pub struct PrometeuHub {
|
||||||
@ -83,6 +87,7 @@ impl NativeShellApp {
|
|||||||
pub enum SystemProfileAction {
|
pub enum SystemProfileAction {
|
||||||
LaunchNativeShell(NativeShellApp),
|
LaunchNativeShell(NativeShellApp),
|
||||||
LaunchGame { target: GameSwitchTarget },
|
LaunchGame { target: GameSwitchTarget },
|
||||||
|
KillResidentGame,
|
||||||
CloseShell,
|
CloseShell,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,12 +136,14 @@ impl PrometeuHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let input = platform.input();
|
let input = platform.input();
|
||||||
|
let suspended_game = if in_shell { None } else { resident_game_indicator(os) };
|
||||||
if let Some(action) = action_for_click(
|
if let Some(action) = action_for_click(
|
||||||
in_shell,
|
in_shell,
|
||||||
input.touch().x(),
|
input.touch().x(),
|
||||||
input.touch().y(),
|
input.touch().y(),
|
||||||
input.touch().f().pressed,
|
input.touch().f().pressed,
|
||||||
self.game_library.entries(),
|
self.game_library.entries(),
|
||||||
|
suspended_game.as_ref(),
|
||||||
) {
|
) {
|
||||||
return Some(action);
|
return Some(action);
|
||||||
}
|
}
|
||||||
@ -149,7 +156,13 @@ impl PrometeuHub {
|
|||||||
let pointer_y = platform.input().touch().y();
|
let pointer_y = platform.input().touch().y();
|
||||||
|
|
||||||
let packet = if os.windows().window_count() == 0 {
|
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 {
|
} else {
|
||||||
let mut commands = Vec::new();
|
let mut commands = Vec::new();
|
||||||
for window in os.windows().windows() {
|
for window in os.windows().windows() {
|
||||||
@ -232,6 +245,7 @@ fn action_for_click(
|
|||||||
y: i32,
|
y: i32,
|
||||||
pressed: bool,
|
pressed: bool,
|
||||||
game_entries: &[crate::GameLibraryEntry],
|
game_entries: &[crate::GameLibraryEntry],
|
||||||
|
suspended_game: Option<&SuspendedGameIndicator>,
|
||||||
) -> Option<SystemProfileAction> {
|
) -> Option<SystemProfileAction> {
|
||||||
if !pressed {
|
if !pressed {
|
||||||
return None;
|
return None;
|
||||||
@ -241,6 +255,10 @@ fn action_for_click(
|
|||||||
return rect_contains(CLOSE_BUTTON, x, y).then_some(SystemProfileAction::CloseShell);
|
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
|
if let Some(entry) = game_entries
|
||||||
.iter()
|
.iter()
|
||||||
.take(MAX_HOME_GAME_ROWS)
|
.take(MAX_HOME_GAME_ROWS)
|
||||||
@ -257,6 +275,23 @@ fn action_for_click(
|
|||||||
.map(|button| SystemProfileAction::LaunchNativeShell(button.app))
|
.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 {
|
fn game_row_rect(index: usize) -> Rect {
|
||||||
Rect {
|
Rect {
|
||||||
x: GAME_ROW_X,
|
x: GAME_ROW_X,
|
||||||
@ -281,6 +316,7 @@ fn render_home_packet(
|
|||||||
pointer_x: i32,
|
pointer_x: i32,
|
||||||
pointer_y: i32,
|
pointer_y: i32,
|
||||||
game_entries: &[crate::GameLibraryEntry],
|
game_entries: &[crate::GameLibraryEntry],
|
||||||
|
suspended_game: Option<&SuspendedGameIndicator>,
|
||||||
) -> ShellUiFramePacket {
|
) -> ShellUiFramePacket {
|
||||||
let mut commands = Vec::new();
|
let mut commands = Vec::new();
|
||||||
commands.push(GfxUiCommand::Clear { color: COLOR_BG });
|
commands.push(GfxUiCommand::Clear { color: COLOR_BG });
|
||||||
@ -320,6 +356,11 @@ fn render_home_packet(
|
|||||||
draw_button(&mut commands, button.rect, button.label, hovered);
|
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)
|
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);
|
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) {
|
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 });
|
commands.push(GfxUiCommand::DrawText { x, y, text: text.to_string(), color });
|
||||||
}
|
}
|
||||||
@ -462,7 +536,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shell_a_button_click_emits_launch_action() {
|
fn shell_a_button_click_emits_launch_action() {
|
||||||
assert_eq!(
|
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))
|
Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellA))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -475,7 +556,8 @@ mod tests {
|
|||||||
SHELL_B_BUTTON.x + SHELL_B_BUTTON.w - 1,
|
SHELL_B_BUTTON.x + SHELL_B_BUTTON.w - 1,
|
||||||
SHELL_B_BUTTON.y,
|
SHELL_B_BUTTON.y,
|
||||||
true,
|
true,
|
||||||
&[]
|
&[],
|
||||||
|
None
|
||||||
),
|
),
|
||||||
Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellB))
|
Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellB))
|
||||||
);
|
);
|
||||||
@ -489,7 +571,8 @@ mod tests {
|
|||||||
SHELL_A_BUTTON.x + SHELL_A_BUTTON.w,
|
SHELL_A_BUTTON.x + SHELL_A_BUTTON.w,
|
||||||
SHELL_A_BUTTON.y,
|
SHELL_A_BUTTON.y,
|
||||||
true,
|
true,
|
||||||
&[]
|
&[],
|
||||||
|
None
|
||||||
),
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
@ -499,7 +582,8 @@ mod tests {
|
|||||||
SHELL_A_BUTTON.x,
|
SHELL_A_BUTTON.x,
|
||||||
SHELL_A_BUTTON.y + SHELL_A_BUTTON.h,
|
SHELL_A_BUTTON.y + SHELL_A_BUTTON.h,
|
||||||
true,
|
true,
|
||||||
&[]
|
&[],
|
||||||
|
None
|
||||||
),
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
@ -507,7 +591,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn held_pointer_does_not_emit_click_action() {
|
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]
|
#[test]
|
||||||
@ -535,8 +622,14 @@ mod tests {
|
|||||||
discovered_at: std::time::SystemTime::UNIX_EPOCH,
|
discovered_at: std::time::SystemTime::UNIX_EPOCH,
|
||||||
};
|
};
|
||||||
|
|
||||||
let action =
|
let action = action_for_click(
|
||||||
action_for_click(false, GAME_ROW_X, GAME_ROW_Y, true, std::slice::from_ref(&entry));
|
false,
|
||||||
|
GAME_ROW_X,
|
||||||
|
GAME_ROW_Y,
|
||||||
|
true,
|
||||||
|
std::slice::from_ref(&entry),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
action,
|
action,
|
||||||
@ -554,10 +647,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn close_button_click_emits_close_only_in_shell() {
|
fn close_button_click_emits_close_only_in_shell() {
|
||||||
assert_eq!(
|
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)
|
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]
|
#[test]
|
||||||
@ -571,7 +664,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn home_render_produces_shell_ui_packet_commands() {
|
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!(matches!(packet.commands.first(), Some(GfxUiCommand::Clear { .. })));
|
||||||
assert!(packet.commands.iter().any(|command| {
|
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]
|
#[test]
|
||||||
fn shell_close_button_sits_inside_shell_frame() {
|
fn shell_close_button_sits_inside_shell_frame() {
|
||||||
assert!(rect_contains(SHELL_FRAME, CLOSE_BUTTON.x, CLOSE_BUTTON.y));
|
assert!(rect_contains(SHELL_FRAME, CLOSE_BUTTON.x, CLOSE_BUTTON.y));
|
||||||
|
|||||||
@ -315,4 +315,22 @@ mod tests {
|
|||||||
assert_eq!(library.diagnostics().len(), 1);
|
assert_eq!(library.diagnostics().len(), 1);
|
||||||
assert_eq!(library.diagnostics()[0].reason, GameLibraryDiagnosticReason::RootNotDirectory);
|
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")
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,16 +26,22 @@ const IDLE_HOST_POLL_DT: Duration = Duration::from_millis(100);
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct PresentationState {
|
struct PresentationState {
|
||||||
latest_published_frame: u64,
|
latest_published_frame: Option<PresentationFrameMarker>,
|
||||||
last_presented_frame: Option<u64>,
|
last_presented_frame: Option<PresentationFrameMarker>,
|
||||||
host_invalidated: bool,
|
host_invalidated: bool,
|
||||||
redraw_requested: bool,
|
redraw_requested: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct PresentationFrameMarker {
|
||||||
|
ownership: RenderOwnership,
|
||||||
|
frame_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for PresentationState {
|
impl Default for PresentationState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
latest_published_frame: 0,
|
latest_published_frame: None,
|
||||||
last_presented_frame: None,
|
last_presented_frame: None,
|
||||||
host_invalidated: true,
|
host_invalidated: true,
|
||||||
redraw_requested: false,
|
redraw_requested: false,
|
||||||
@ -44,9 +50,10 @@ impl Default for PresentationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PresentationState {
|
impl PresentationState {
|
||||||
fn note_published_frame(&mut self, frame_index: u64) {
|
fn note_published_frame(&mut self, ownership: RenderOwnership, frame_id: u64) {
|
||||||
if frame_index > self.latest_published_frame {
|
let marker = PresentationFrameMarker { ownership, frame_id };
|
||||||
self.latest_published_frame = frame_index;
|
if self.latest_published_frame != Some(marker) {
|
||||||
|
self.latest_published_frame = Some(marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +62,7 @@ impl PresentationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn needs_redraw(&self) -> bool {
|
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 {
|
fn should_request_redraw(&mut self) -> bool {
|
||||||
@ -70,7 +77,7 @@ impl PresentationState {
|
|||||||
fn mark_presented(&mut self) {
|
fn mark_presented(&mut self) {
|
||||||
self.redraw_requested = false;
|
self.redraw_requested = false;
|
||||||
self.host_invalidated = 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()
|
if let Some(worker_frame) = self.render_frame_store.latest_frame()
|
||||||
&& should_present_worker_frame(&self.firmware.state, &worker_frame, active_ownership)
|
&& 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 {
|
} 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()
|
if was_debugger_connected != self.debugger.stream.is_some()
|
||||||
@ -505,14 +514,37 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn presentation_state_requests_redraw_for_new_frame_publication() {
|
fn presentation_state_requests_redraw_for_new_frame_publication() {
|
||||||
|
let ownership = RenderOwnership::new(1, AppMode::Game, 1);
|
||||||
let mut state = PresentationState::default();
|
let mut state = PresentationState::default();
|
||||||
state.mark_presented();
|
state.mark_presented();
|
||||||
|
|
||||||
state.note_published_frame(1);
|
state.note_published_frame(ownership, 1);
|
||||||
|
|
||||||
assert!(state.should_request_redraw());
|
assert!(state.should_request_redraw());
|
||||||
state.mark_presented();
|
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]
|
#[test]
|
||||||
@ -608,11 +640,14 @@ mod tests {
|
|||||||
let mut presentation = PresentationState::default();
|
let mut presentation = PresentationState::default();
|
||||||
presentation.mark_presented();
|
presentation.mark_presented();
|
||||||
let published = store.latest_frame().expect("published frame");
|
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());
|
assert!(presentation.should_request_redraw());
|
||||||
presentation.mark_presented();
|
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]
|
#[test]
|
||||||
|
|||||||
12
crates/tools/pbxgen-dummy-boy/Cargo.toml
Normal file
12
crates/tools/pbxgen-dummy-boy/Cargo.toml
Normal 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"
|
||||||
147
crates/tools/pbxgen-dummy-boy/src/lib.rs
Normal file
147
crates/tools/pbxgen-dummy-boy/src/lib.rs
Normal 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"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/tools/pbxgen-dummy-boy/src/main.rs
Normal file
5
crates/tools/pbxgen-dummy-boy/src/main.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
pbxgen_dummy_boy::generate()
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
{"type":"meta","next_id":{"DSC":44,"AGD":47,"DEC":40,"PLN":163,"LSN":53,"CLSN":1}}
|
{"type":"meta","next_id":{"DSC":45,"AGD":48,"DEC":41,"PLN":167,"LSN":55,"CLSN":1}}
|
||||||
{"type":"discussion","id":"DSC-0043","status":"in_progress","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0039","file":"DEC-0039-systemos-game-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0044"}],"plans":[{"id":"PLN-0157","file":"PLN-0157-introduce-systemos-game-switch-target-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0158","file":"PLN-0158-terminate-resident-game-before-switching-cartridges.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0159","file":"PLN-0159-recover-failed-game-switches-back-to-home.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0160","file":"PLN-0160-add-dummy-boy-visual-switch-test-cartridge.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0161","file":"PLN-0161-validate-stress-to-dummy-boy-game-switch-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0162","file":"PLN-0162-specify-game-switch-contract-and-direct-boot-separation.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]}],"lessons":[]}
|
{"type":"discussion","id":"DSC-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-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-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"}]}
|
{"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"}]}
|
||||||
|
|||||||
@ -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.
|
||||||
@ -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.
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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`
|
|
||||||
@ -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`
|
|
||||||
@ -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`
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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`
|
|
||||||
@ -128,6 +128,11 @@ SystemOS resume request
|
|||||||
scheduler/runtime state. A Game may observe pause/resume, but it must not
|
scheduler/runtime state. A Game may observe pause/resume, but it must not
|
||||||
control whether SystemOS suspends or resumes the VM.
|
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
|
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,
|
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
|
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
|
staging state are session-scoped unless another spec explicitly makes them
|
||||||
durable app data.
|
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
|
## 9 Render Worker Concurrency
|
||||||
|
|
||||||
The render worker is not a machine-visible event source and does not introduce
|
The render worker is not a machine-visible event source and does not introduce
|
||||||
|
|||||||
@ -44,6 +44,11 @@ PROMETEU operates in three main modes:
|
|||||||
|
|
||||||
No mode alters the logical result of the program.
|
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 Execution Control
|
||||||
|
|
||||||
### 3.1 Pause and Resume
|
### 3.1 Pause and Resume
|
||||||
|
|||||||
@ -43,6 +43,9 @@ The VM does not own the machine lifecycle. Firmware does.
|
|||||||
Game directory cartridges.
|
Game directory cartridges.
|
||||||
- **Library entry**: an internal catalog record retained by Hub/SystemOS for a
|
- **Library entry**: an internal catalog record retained by Hub/SystemOS for a
|
||||||
discovered cartridge.
|
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
|
- **Foreground visual owner**: the single machine owner whose visual output may
|
||||||
be presented at a given time.
|
be presented at a given time.
|
||||||
- **Paused**: a Game-visible lifecycle event/state. The Game may observe it and
|
- **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.
|
frame pacing.
|
||||||
- **VM session**: the POS-owned mutable execution context for one VM-backed
|
- **VM session**: the POS-owned mutable execution context for one VM-backed
|
||||||
process. It contains the VM instance and per-session runtime state.
|
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
|
## 3 POS Responsibilities
|
||||||
|
|
||||||
@ -86,6 +91,8 @@ PrometeuHub is responsible for:
|
|||||||
- presenting the host-provided local games library when `--games-root <dir>` is
|
- presenting the host-provided local games library when `--games-root <dir>` is
|
||||||
configured;
|
configured;
|
||||||
- emitting system-owned launch actions for selected Home entries.
|
- 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.
|
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
|
pause handoff and later foreground resume. A VM-backed Shell has a separate VM
|
||||||
session. A native Shell has no VM session.
|
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
|
## 8 Cartridge Load Flow
|
||||||
|
|
||||||
Current high-level flow:
|
Current high-level flow:
|
||||||
@ -231,11 +249,51 @@ Current high-level flow:
|
|||||||
If VM initialization fails, firmware transitions to the crash path.
|
If VM initialization fails, firmware transitions to the crash path.
|
||||||
|
|
||||||
For v1, POS must enforce at most one resident Game session. Selecting the same
|
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 from Home resumes that session. Selecting a different Game from
|
||||||
resident Game exists is outside the v1 foreground contract and must not create a
|
Home is a SystemOS-owned Game switch and MUST be destructive replacement:
|
||||||
second resident Game session. VM-backed Shell loading creates a distinct Shell
|
|
||||||
|
```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.
|
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`.
|
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
|
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
|
machine in the Hub/Home flow and record the failure through diagnostics or
|
||||||
logging. A polished Home error surface is not required by v1.
|
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
|
## 9 Firmware States
|
||||||
|
|
||||||
The current firmware state model includes:
|
The current firmware state model includes:
|
||||||
@ -310,6 +378,9 @@ Expected behavior:
|
|||||||
resident Game session;
|
resident Game session;
|
||||||
- returning to the same Game resumes it through the foreground lifecycle and
|
- returning to the same Game resumes it through the foreground lifecycle and
|
||||||
waits for current-epoch Game rendering before visual Game presentation.
|
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
|
## 13 Relationship to Other Specs
|
||||||
|
|
||||||
|
|||||||
@ -186,6 +186,9 @@ For the v1 Home games library:
|
|||||||
- the manifest must declare `app_mode: "Game"`;
|
- the manifest must declare `app_mode: "Game"`;
|
||||||
- the library retains the loaded manifest data, `title`, `app_id`,
|
- the library retains the loaded manifest data, `title`, `app_id`,
|
||||||
`app_version`, the internal cartridge path, and discovery metadata;
|
`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
|
- invalid candidates are logged or reported as diagnostics and omitted from the
|
||||||
Home list;
|
Home list;
|
||||||
- valid non-Game cartridges are omitted from the Game library.
|
- 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
|
Discovery reads metadata for cataloging. It does not replace the normal
|
||||||
cartridge loader used when firmware starts the selected cartridge.
|
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
|
### Packaged `.pmc` form
|
||||||
|
|
||||||
This form is recognized conceptually by the loader boundary, but its actual load path is not implemented yet.
|
This form is recognized conceptually by the loader boundary, but its actual load path is not implemented yet.
|
||||||
|
|||||||
@ -97,9 +97,11 @@ The v1 games root contract is:
|
|||||||
and transitions through `LoadCartridge`;
|
and transitions through `LoadCartridge`;
|
||||||
- launch failure leaves or returns the machine to Home and records an error.
|
- 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
|
Game-to-game switching after a game is already active is not a boot profile.
|
||||||
profile. That orchestration belongs to the foreground/home and cartridge switch
|
That orchestration belongs to the foreground/home and cartridge switch
|
||||||
contracts.
|
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
|
The foreground/home contract defines `Game -> Home/Shell -> same Game` after a
|
||||||
Game is already running. It does not change direct `--run`, debugger direct
|
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
|
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.
|
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
|
## 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
|
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
|
## 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.
|
- [`12-firmware-pos-and-prometeuhub.md`](12-firmware-pos-and-prometeuhub.md) defines the firmware state machine that consumes boot targets.
|
||||||
|
|||||||
11
test-cartridges/dummy-boy/README.md
Normal file
11
test-cartridges/dummy-boy/README.md
Normal 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.
|
||||||
9
test-cartridges/dummy-boy/manifest.json
Normal file
9
test-cartridges/dummy-boy/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
BIN
test-cartridges/dummy-boy/program.pbx
Normal file
BIN
test-cartridges/dummy-boy/program.pbx
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user