Merge pull request 'dev/system-os-cartridge-switch-orchestrator' (#36) from dev/system-os-cartridge-switch-orchestrator into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
Reviewed-on: #36
This commit is contained in:
commit
3cce8a2b25
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;
|
||||||
@ -289,20 +292,45 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_game(&self, name: &str, with_program: bool) -> PathBuf {
|
fn add_game(&self, name: &str, with_program: bool) -> PathBuf {
|
||||||
|
self.add_game_with_identity(name, 42, "Stress", "1.0.0", with_program)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_game_with_identity(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
app_id: u32,
|
||||||
|
title: &str,
|
||||||
|
app_version: &str,
|
||||||
|
with_program: bool,
|
||||||
|
) -> PathBuf {
|
||||||
|
let program = with_program.then(halting_program);
|
||||||
|
self.add_game_with_program(name, app_id, title, app_version, program)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_game_with_program(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
app_id: u32,
|
||||||
|
title: &str,
|
||||||
|
app_version: &str,
|
||||||
|
program: Option<Vec<u8>>,
|
||||||
|
) -> PathBuf {
|
||||||
let cart = self.path.join(name);
|
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");
|
||||||
let manifest = r#"{
|
let manifest = format!(
|
||||||
"magic": "PMTU",
|
r#"{{
|
||||||
"cartridge_version": 1,
|
"magic": "PMTU",
|
||||||
"app_id": 42,
|
"cartridge_version": 1,
|
||||||
"title": "Stress",
|
"app_id": {app_id},
|
||||||
"app_version": "1.0.0",
|
"title": "{title}",
|
||||||
"app_mode": "Game"
|
"app_version": "{app_version}",
|
||||||
}"#;
|
"app_mode": "Game"
|
||||||
|
}}"#
|
||||||
|
);
|
||||||
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
|
||||||
@ -333,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,
|
||||||
@ -447,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);
|
||||||
@ -728,6 +766,499 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hub_home_clicking_different_game_terminates_previous_resident_session() {
|
||||||
|
let root = TestGameRoot::new();
|
||||||
|
root.add_game("stress", true);
|
||||||
|
root.add_game_with_identity("zzz-dummy", 43, "Zzz Dummy", "1.0.0", true);
|
||||||
|
|
||||||
|
let (mut firmware, mut platform) = hub_home_firmware();
|
||||||
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
||||||
|
firmware.tick(&stress_click, &mut platform);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let first_game_task = match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => step.task_id,
|
||||||
|
other => panic!("expected first GameRunning state, got {:?}", other),
|
||||||
|
};
|
||||||
|
|
||||||
|
firmware.request_home_from_host();
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(first_game_task));
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
let dummy_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
||||||
|
firmware.tick(&dummy_click, &mut platform);
|
||||||
|
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.lifecycle().process_state_for_task(first_game_task),
|
||||||
|
Ok(ProcessState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
firmware
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.vm_session_for_task(first_game_task)
|
||||||
|
.map(|session| session.task_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
let second_game_task = firmware
|
||||||
|
.os
|
||||||
|
.lifecycle()
|
||||||
|
.resident_game_task()
|
||||||
|
.expect("replacement game should be resident");
|
||||||
|
assert_ne!(second_game_task, first_game_task);
|
||||||
|
assert_eq!(
|
||||||
|
firmware
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.vm_session_for_task(second_game_task)
|
||||||
|
.map(|session| session.app_id),
|
||||||
|
Some(43)
|
||||||
|
);
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_game_task),
|
||||||
|
other => panic!("expected replacement GameRunning state, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_stress_to_dummy_boy_switch_is_end_to_end() {
|
||||||
|
let library = discover_games_root(repository_test_cartridges_root());
|
||||||
|
assert!(
|
||||||
|
library
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.app_id == 1 && entry.title == "Stress Console")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
library.entries().iter().any(|entry| entry.app_id == 2 && entry.title == "Dummy Boy")
|
||||||
|
);
|
||||||
|
|
||||||
|
let (mut firmware, mut platform) = hub_home_firmware();
|
||||||
|
firmware.hub.set_game_library(library);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
||||||
|
firmware.tick(&stress_click, &mut platform);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_task = match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => step.task_id,
|
||||||
|
other => panic!("expected Stress GameRunning state, got {:?}", other),
|
||||||
|
};
|
||||||
|
assert_eq!(firmware.os.vm().current_app_id(), 1);
|
||||||
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console");
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
||||||
|
Some(100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
firmware.request_home_from_host();
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
let hub_ownership = firmware.os.vm().active_render_ownership();
|
||||||
|
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
|
||||||
|
assert_eq!(hub_ownership.app_id, 0);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
||||||
|
Some(100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
let dummy_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
||||||
|
firmware.tick(&dummy_click, &mut platform);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
|
||||||
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
|
||||||
|
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.lifecycle().process_state_for_task(stress_task),
|
||||||
|
Ok(ProcessState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
let dummy_task = firmware
|
||||||
|
.os
|
||||||
|
.lifecycle()
|
||||||
|
.resident_game_task()
|
||||||
|
.expect("Dummy Boy should be resident after switch load");
|
||||||
|
assert_ne!(dummy_task, stress_task);
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| {
|
||||||
|
(session.app_id, session.title.clone(), session.app_version.clone())
|
||||||
|
}),
|
||||||
|
Some((2, "Dummy Boy".to_string(), "0.1.0".to_string()))
|
||||||
|
);
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, dummy_task),
|
||||||
|
other => panic!("expected Dummy Boy GameRunning state, got {:?}", other),
|
||||||
|
}
|
||||||
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(dummy_task));
|
||||||
|
assert_eq!(firmware.os.vm().current_app_id(), 2);
|
||||||
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Dummy Boy");
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let dummy_ownership = firmware.os.vm().active_render_ownership();
|
||||||
|
assert_eq!(dummy_ownership.app_mode, AppMode::Game);
|
||||||
|
assert_eq!(dummy_ownership.app_id, 2);
|
||||||
|
|
||||||
|
let front = platform.local_hardware().gfx.front_buffer();
|
||||||
|
let center = 240 + 135 * 480;
|
||||||
|
assert_eq!(front[center], Color::GREEN.raw());
|
||||||
|
|
||||||
|
firmware.request_home_from_host();
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
let hub_ownership = firmware.os.vm().active_render_ownership();
|
||||||
|
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
|
||||||
|
assert_eq!(hub_ownership.app_id, 0);
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
let stress_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
||||||
|
firmware.tick(&stress_click, &mut platform);
|
||||||
|
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(dummy_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.lifecycle().process_state_for_task(dummy_task),
|
||||||
|
Ok(ProcessState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| session.task_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
||||||
|
Some(100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some());
|
||||||
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some());
|
||||||
|
|
||||||
|
let second_stress_task = firmware
|
||||||
|
.os
|
||||||
|
.lifecycle()
|
||||||
|
.resident_game_task()
|
||||||
|
.expect("Stress should be resident after switching back");
|
||||||
|
assert_ne!(second_stress_task, stress_task);
|
||||||
|
assert_ne!(second_stress_task, dummy_task);
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_stress_task),
|
||||||
|
other => panic!("expected second Stress GameRunning state, got {:?}", other),
|
||||||
|
}
|
||||||
|
assert_eq!(firmware.os.vm().current_app_id(), 1);
|
||||||
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console");
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_ownership = firmware.os.vm().active_render_ownership();
|
||||||
|
assert_eq!(stress_ownership.app_mode, AppMode::Game);
|
||||||
|
assert_eq!(stress_ownership.app_id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resident_game_termination_noops_when_no_game_is_resident() {
|
||||||
|
let (mut firmware, mut platform) = hub_home_firmware();
|
||||||
|
let ownership_before = firmware.os.vm().active_render_ownership();
|
||||||
|
|
||||||
|
let outcome = resident_game_termination::terminate_resident_game(
|
||||||
|
&mut firmware.os,
|
||||||
|
&mut platform,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("no resident game termination should not fail");
|
||||||
|
|
||||||
|
assert_eq!(outcome, ResidentGameTerminationOutcome::NoResidentGame);
|
||||||
|
assert_eq!(firmware.os.vm().active_render_ownership(), ownership_before);
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
|
||||||
|
assert!(
|
||||||
|
!firmware
|
||||||
|
.os
|
||||||
|
.recent_logs(16)
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.msg.contains("Resident game killed from Hub"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resident_game_termination_closes_session_and_cleans_runtime_state() {
|
||||||
|
let library = discover_games_root(repository_test_cartridges_root());
|
||||||
|
let (mut firmware, mut platform) = hub_home_firmware();
|
||||||
|
firmware.hub.set_game_library(library);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
||||||
|
firmware.tick(&stress_click, &mut platform);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_task = match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => step.task_id,
|
||||||
|
other => panic!("expected Stress GameRunning state, got {:?}", other),
|
||||||
|
};
|
||||||
|
|
||||||
|
firmware.request_home_from_host();
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(stress_task));
|
||||||
|
assert!(firmware.game_lifecycle_audio_paused());
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
||||||
|
Some(100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some());
|
||||||
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some());
|
||||||
|
|
||||||
|
let outcome = resident_game_termination::terminate_resident_game(
|
||||||
|
&mut firmware.os,
|
||||||
|
&mut platform,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("resident game termination should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
ResidentGameTerminationOutcome::Terminated {
|
||||||
|
task_id: stress_task,
|
||||||
|
title: "Stress Console".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.lifecycle().process_state_for_task(stress_task),
|
||||||
|
Ok(ProcessState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(!firmware.game_lifecycle_audio_paused());
|
||||||
|
|
||||||
|
let ownership = firmware.os.vm().active_render_ownership();
|
||||||
|
assert_eq!(ownership.app_mode, AppMode::Shell);
|
||||||
|
assert_eq!(ownership.app_id, 0);
|
||||||
|
assert_eq!(platform.local_hardware().frame_composer.active_scene_id(), None);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
|
||||||
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
firmware.os.recent_logs(32).iter().any(|event| {
|
||||||
|
event.level == LogLevel::Info
|
||||||
|
&& event.source == LogSource::Hub
|
||||||
|
&& event.msg == "Resident game killed from Hub: Stress Console"
|
||||||
|
}),
|
||||||
|
"manual Hub kill should emit an INFO log"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hub_kill_control_click_terminates_resident_game_and_stays_home() {
|
||||||
|
let library = discover_games_root(repository_test_cartridges_root());
|
||||||
|
let (mut firmware, mut platform) = hub_home_firmware();
|
||||||
|
firmware.hub.set_game_library(library);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
||||||
|
firmware.tick(&stress_click, &mut platform);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_task = match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => step.task_id,
|
||||||
|
other => panic!("expected Stress GameRunning state, got {:?}", other),
|
||||||
|
};
|
||||||
|
|
||||||
|
firmware.request_home_from_host();
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(stress_task));
|
||||||
|
|
||||||
|
let kill_sample_x = HUB_SUSPENDED_GAME_KILL_CONTROL.x + 2;
|
||||||
|
let kill_sample_y = HUB_SUSPENDED_GAME_KILL_CONTROL.y + 2;
|
||||||
|
assert_eq!(
|
||||||
|
front_buffer_pixel(&platform, kill_sample_x, kill_sample_y),
|
||||||
|
Color::rgb(164, 32, 40).raw(),
|
||||||
|
"Hub should render the suspended-game kill control before click"
|
||||||
|
);
|
||||||
|
|
||||||
|
let kill_click = InputSignals {
|
||||||
|
f_signal: true,
|
||||||
|
x_pos: HUB_SUSPENDED_GAME_KILL_CONTROL.x,
|
||||||
|
y_pos: HUB_SUSPENDED_GAME_KILL_CONTROL.y,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
firmware.tick(&kill_click, &mut platform);
|
||||||
|
|
||||||
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
||||||
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game(), None);
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.lifecycle().process_state_for_task(stress_task),
|
||||||
|
Ok(ProcessState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(!firmware.game_lifecycle_audio_paused());
|
||||||
|
assert!(!firmware.resume_resident_game_from_home());
|
||||||
|
|
||||||
|
let ownership = firmware.os.vm().active_render_ownership();
|
||||||
|
assert_eq!(ownership.app_mode, AppMode::Shell);
|
||||||
|
assert_eq!(ownership.app_id, 0);
|
||||||
|
assert_eq!(platform.local_hardware().frame_composer.active_scene_id(), None);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
|
||||||
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
|
||||||
|
assert!(
|
||||||
|
firmware.os.recent_logs(32).iter().any(|event| {
|
||||||
|
event.level == LogLevel::Info
|
||||||
|
&& event.source == LogSource::Hub
|
||||||
|
&& event.msg == "Resident game killed from Hub: Stress Console"
|
||||||
|
}),
|
||||||
|
"Hub kill control should use the canonical manual kill log"
|
||||||
|
);
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
assert_eq!(
|
||||||
|
front_buffer_pixel(&platform, kill_sample_x, kill_sample_y),
|
||||||
|
Color::rgb(12, 14, 22).raw(),
|
||||||
|
"Hub should stop rendering the kill control after the game is gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_game_switch_returns_home_without_restoring_previous_game() {
|
||||||
|
let root = TestGameRoot::new();
|
||||||
|
root.add_game("stress", true);
|
||||||
|
root.add_game_with_program("zzz-broken", 43, "Zzz Broken", "1.0.0", Some(vec![0, 0, 0, 0]));
|
||||||
|
|
||||||
|
let (mut firmware, mut platform) = hub_home_firmware();
|
||||||
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let stress_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
||||||
|
firmware.tick(&stress_click, &mut platform);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let first_game_task = match &firmware.state {
|
||||||
|
FirmwareState::GameRunning(step) => step.task_id,
|
||||||
|
other => panic!("expected first GameRunning state, got {:?}", other),
|
||||||
|
};
|
||||||
|
|
||||||
|
firmware.request_home_from_host();
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
let broken_click =
|
||||||
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
||||||
|
firmware.tick(&broken_click, &mut platform);
|
||||||
|
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(
|
||||||
|
firmware
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.vm_session_for_task(first_game_task)
|
||||||
|
.map(|session| session.task_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
let partial_task = firmware
|
||||||
|
.os
|
||||||
|
.lifecycle()
|
||||||
|
.resident_game_task()
|
||||||
|
.expect("failed target should be resident until recovery tick");
|
||||||
|
assert_ne!(partial_task, first_game_task);
|
||||||
|
|
||||||
|
firmware.tick(&InputSignals::default(), &mut platform);
|
||||||
|
|
||||||
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
||||||
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(firmware.os.lifecycle().task_state(partial_task), Some(TaskState::Closed));
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.lifecycle().process_state_for_task(partial_task),
|
||||||
|
Ok(ProcessState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
firmware.os.sessions().vm_session_for_task(partial_task).map(|session| session.task_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
firmware
|
||||||
|
.os
|
||||||
|
.recent_logs(16)
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.msg.contains("Failed to switch to game Zzz Broken app_id 43"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
||||||
@ -910,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();
|
||||||
|
|
||||||
@ -941,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),
|
||||||
@ -970,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(_)));
|
||||||
@ -982,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};
|
||||||
@ -52,45 +53,71 @@ impl HubHomeStep {
|
|||||||
ctx.os.windows().set_focus(id);
|
ctx.os.windows().set_focus(id);
|
||||||
return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id)));
|
return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id)));
|
||||||
}
|
}
|
||||||
Some(SystemProfileAction::LaunchGame { path }) => match CartridgeLoader::load(&path) {
|
Some(SystemProfileAction::LaunchGame { target }) => {
|
||||||
Ok(cartridge) => {
|
match CartridgeLoader::load(&target.path) {
|
||||||
if cartridge.app_mode == AppMode::Game
|
Ok(cartridge) => {
|
||||||
&& let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task()
|
if cartridge.app_mode == AppMode::Game
|
||||||
{
|
&& let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task()
|
||||||
let resident_app_id = ctx
|
{
|
||||||
.os
|
let resident_app_id = ctx
|
||||||
.sessions()
|
.os
|
||||||
.vm_session_for_task(resident_task_id)
|
.sessions()
|
||||||
.map(|session| session.app_id)
|
.vm_session_for_task(resident_task_id)
|
||||||
.unwrap_or(0);
|
.map(|session| session.app_id)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
if resident_app_id == cartridge.app_id {
|
if resident_app_id == cartridge.app_id {
|
||||||
return Some(FirmwareState::ResumeResidentGame);
|
return Some(FirmwareState::ResumeResidentGame);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = resident_game_termination::terminate_resident_game(
|
||||||
|
ctx.os,
|
||||||
|
ctx.platform,
|
||||||
|
false,
|
||||||
|
) {
|
||||||
|
ctx.os.log(
|
||||||
|
LogLevel::Error,
|
||||||
|
LogSource::Hub,
|
||||||
|
0,
|
||||||
|
format!(
|
||||||
|
"Failed to terminate resident game app_id {} before launching {}: {:?}",
|
||||||
|
resident_app_id, cartridge.title, error
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Some(FirmwareState::LoadCartridge(
|
||||||
|
LoadCartridgeStep::new_game_switch(cartridge),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
ctx.os.log(
|
ctx.os.log(
|
||||||
LogLevel::Warn,
|
LogLevel::Error,
|
||||||
LogSource::Hub,
|
LogSource::Hub,
|
||||||
0,
|
0,
|
||||||
format!(
|
format!(
|
||||||
"Cannot launch Home game {} while resident game app_id {} is active",
|
"Failed to launch Home game {}: {:?}",
|
||||||
cartridge.title, resident_app_id
|
target.path.display(),
|
||||||
|
error
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge)));
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
}
|
||||||
|
Some(SystemProfileAction::KillResidentGame) => {
|
||||||
|
if let Err(error) =
|
||||||
|
resident_game_termination::terminate_resident_game(ctx.os, ctx.platform, true)
|
||||||
|
{
|
||||||
ctx.os.log(
|
ctx.os.log(
|
||||||
LogLevel::Error,
|
LogLevel::Error,
|
||||||
LogSource::Hub,
|
LogSource::Hub,
|
||||||
0,
|
0,
|
||||||
format!("Failed to launch Home game {}: {:?}", path.display(), error),
|
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,
|
||||||
@ -20,7 +23,7 @@ pub use services::foreground::{
|
|||||||
};
|
};
|
||||||
pub use services::fs;
|
pub use services::fs;
|
||||||
pub use services::game_library::{
|
pub use services::game_library::{
|
||||||
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root,
|
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, GameSwitchTarget, discover_games_root,
|
||||||
};
|
};
|
||||||
pub use services::memcard::MemcardAsyncLaneOperation;
|
pub use services::memcard::MemcardAsyncLaneOperation;
|
||||||
pub use services::process;
|
pub use services::process;
|
||||||
|
|||||||
@ -222,6 +222,28 @@ impl<'a> LifecycleFacade<'a> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn terminate_resident_game(&mut self) -> Result<Option<TaskId>, LifecycleError> {
|
||||||
|
let Some(task_id) = self.os.foreground_stack.resident_game_task() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let process_id = process_id_for_task(self.os, task_id)?;
|
||||||
|
|
||||||
|
self.os.task_manager.close_task(task_id);
|
||||||
|
self.os.process_manager.mark_stopped(process_id);
|
||||||
|
self.os.foreground_stack.clear_resident_game(task_id);
|
||||||
|
self.os.vm_sessions.remove_for_task(task_id);
|
||||||
|
self.os.game_lifecycle_events.retain(|event| event.task_id != task_id);
|
||||||
|
|
||||||
|
if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Game(owner) if owner == task_id)
|
||||||
|
{
|
||||||
|
self.os.foreground_stack.return_to_hub();
|
||||||
|
self.os.task_manager.clear_foreground();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(task_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn crash_task(
|
pub fn crash_task(
|
||||||
&mut self,
|
&mut self,
|
||||||
task_id: TaskId,
|
task_id: TaskId,
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,6 +218,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminate_resident_game_closes_task_and_removes_vm_session() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
|
||||||
|
os.lifecycle().request_home_from_game(game).expect("home request should succeed");
|
||||||
|
assert_eq!(os.lifecycle().pending_game_lifecycle_events().len(), 1);
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().terminate_resident_game(), Ok(Some(game)));
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().resident_game(), None);
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed));
|
||||||
|
assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped);
|
||||||
|
assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None);
|
||||||
|
assert!(os.lifecycle().pending_game_lifecycle_events().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminate_resident_game_preserves_foreground_shell() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
os.lifecycle().request_home_from_game(game).expect("home request should succeed");
|
||||||
|
os.lifecycle().advance_game_pause_budget(game).expect("budget should expire");
|
||||||
|
let shell = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().terminate_resident_game(), Ok(Some(game)));
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell));
|
||||||
|
assert_eq!(os.lifecycle().resident_game(), None);
|
||||||
|
assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed));
|
||||||
|
assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped);
|
||||||
|
assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None);
|
||||||
|
assert_eq!(os.lifecycle().task_state(shell), Some(TaskState::Foreground));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vm_game_task_creates_vm_session() {
|
fn vm_game_task_creates_vm_session() {
|
||||||
let mut os = SystemOS::new(None);
|
let mut os = SystemOS::new(None);
|
||||||
|
|||||||
@ -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,12 +1,12 @@
|
|||||||
use crate::task::TaskId;
|
use crate::task::TaskId;
|
||||||
use crate::{CrashReport, GameLibrary, SystemOS};
|
use crate::{CrashReport, GameLibrary, GameSwitchTarget, SystemOS};
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::color::Color;
|
use prometeu_hal::color::Color;
|
||||||
use prometeu_hal::primitives::Rect;
|
use prometeu_hal::primitives::Rect;
|
||||||
use prometeu_hal::{
|
use prometeu_hal::{
|
||||||
FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform,
|
FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform,
|
||||||
ShellUiFramePacket,
|
ShellUiFramePacket,
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 };
|
const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 };
|
||||||
const SHELL_B_BUTTON: Rect = Rect { x: 256, y: 172, w: 112, h: 32 };
|
const SHELL_B_BUTTON: Rect = Rect { x: 256, y: 172, w: 112, h: 32 };
|
||||||
@ -20,6 +20,7 @@ const GAME_ROW_W: i32 = 336;
|
|||||||
const GAME_ROW_H: i32 = 22;
|
const GAME_ROW_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 };
|
||||||
|
|
||||||
@ -32,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,7 +86,8 @@ impl NativeShellApp {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum SystemProfileAction {
|
pub enum SystemProfileAction {
|
||||||
LaunchNativeShell(NativeShellApp),
|
LaunchNativeShell(NativeShellApp),
|
||||||
LaunchGame { path: PathBuf },
|
LaunchGame { target: GameSwitchTarget },
|
||||||
|
KillResidentGame,
|
||||||
CloseShell,
|
CloseShell,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,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);
|
||||||
}
|
}
|
||||||
@ -150,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() {
|
||||||
@ -233,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;
|
||||||
@ -242,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)
|
||||||
@ -249,7 +266,7 @@ fn action_for_click(
|
|||||||
.find(|(index, _entry)| rect_contains(game_row_rect(*index), x, y))
|
.find(|(index, _entry)| rect_contains(game_row_rect(*index), x, y))
|
||||||
.map(|(_index, entry)| entry)
|
.map(|(_index, entry)| entry)
|
||||||
{
|
{
|
||||||
return Some(SystemProfileAction::LaunchGame { path: entry.path.clone() });
|
return Some(SystemProfileAction::LaunchGame { target: entry.switch_target() });
|
||||||
}
|
}
|
||||||
|
|
||||||
HOME_BUTTONS
|
HOME_BUTTONS
|
||||||
@ -258,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,
|
||||||
@ -282,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 });
|
||||||
@ -321,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,6 +463,39 @@ fn draw_game_row(commands: &mut Vec<GfxUiCommand>, rect: Rect, label: &str, hove
|
|||||||
draw_buffer_text(commands, rect.x + 8, rect.y + 7, label, COLOR_TEXT);
|
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 });
|
||||||
}
|
}
|
||||||
@ -434,6 +507,7 @@ mod tests {
|
|||||||
use crate::windows::WindowOwner;
|
use crate::windows::WindowOwner;
|
||||||
use prometeu_drivers::TestPlatform;
|
use prometeu_drivers::TestPlatform;
|
||||||
use prometeu_hal::{InputSignals, RuntimePlatform};
|
use prometeu_hal::{InputSignals, RuntimePlatform};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn focused_native_shell_fixture(
|
fn focused_native_shell_fixture(
|
||||||
signals: &InputSignals,
|
signals: &InputSignals,
|
||||||
@ -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,19 +622,35 @@ mod tests {
|
|||||||
discovered_at: std::time::SystemTime::UNIX_EPOCH,
|
discovered_at: std::time::SystemTime::UNIX_EPOCH,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let action = action_for_click(
|
||||||
|
false,
|
||||||
|
GAME_ROW_X,
|
||||||
|
GAME_ROW_Y,
|
||||||
|
true,
|
||||||
|
std::slice::from_ref(&entry),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
action_for_click(false, GAME_ROW_X, GAME_ROW_Y, true, std::slice::from_ref(&entry)),
|
action,
|
||||||
Some(SystemProfileAction::LaunchGame { path: entry.path.clone() })
|
Some(SystemProfileAction::LaunchGame {
|
||||||
|
target: GameSwitchTarget {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
app_id: 42,
|
||||||
|
title: "Stress".to_string(),
|
||||||
|
app_version: "1.0.0".to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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]
|
||||||
@ -561,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| {
|
||||||
@ -569,6 +672,123 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resident_game_indicator_uses_only_resident_game_sessions() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
assert_eq!(resident_game_indicator(&mut os), None);
|
||||||
|
|
||||||
|
let shell = os.sessions().create_vm_shell_task(7, "VM Shell");
|
||||||
|
assert_eq!(resident_game_indicator(&mut os), None);
|
||||||
|
os.lifecycle().suspend_task(shell).expect("shell suspend should succeed");
|
||||||
|
assert_eq!(resident_game_indicator(&mut os), None);
|
||||||
|
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Stress Console");
|
||||||
|
os.lifecycle().suspend_task(game).expect("game suspend should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resident_game_indicator(&mut os),
|
||||||
|
Some(SuspendedGameIndicator { title: "Stress Console".to_string() })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_render_shows_suspended_game_kill_control_when_game_is_resident() {
|
||||||
|
let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() };
|
||||||
|
let packet = render_home_packet(
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
|
||||||
|
&[],
|
||||||
|
Some(&suspended_game),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(packet.commands.iter().any(|command| {
|
||||||
|
matches!(
|
||||||
|
command,
|
||||||
|
GfxUiCommand::FillRect { rect, color }
|
||||||
|
if *rect == HUB_SUSPENDED_GAME_KILL_CONTROL && *color == COLOR_KILL_ACTIVE
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(packet.commands.iter().any(|command| {
|
||||||
|
matches!(
|
||||||
|
command,
|
||||||
|
GfxUiCommand::DrawText { x, y, text, .. }
|
||||||
|
if *x == HUB_SUSPENDED_GAME_KILL_CONTROL.x + 8
|
||||||
|
&& *y == HUB_SUSPENDED_GAME_KILL_CONTROL.y + 8
|
||||||
|
&& text == "Stress Console"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_render_omits_suspended_game_kill_control_without_resident_game() {
|
||||||
|
let packet = render_home_packet(
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!packet.commands.iter().any(|command| {
|
||||||
|
matches!(
|
||||||
|
command,
|
||||||
|
GfxUiCommand::FillRect { rect, color }
|
||||||
|
if *rect == HUB_SUSPENDED_GAME_KILL_CONTROL
|
||||||
|
&& (*color == COLOR_KILL || *color == COLOR_KILL_ACTIVE)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspended_game_kill_control_click_emits_kill_action() {
|
||||||
|
let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() };
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action_for_click(
|
||||||
|
false,
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
|
||||||
|
true,
|
||||||
|
&[],
|
||||||
|
Some(&suspended_game),
|
||||||
|
),
|
||||||
|
Some(SystemProfileAction::KillResidentGame)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
action_for_click(
|
||||||
|
false,
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.x,
|
||||||
|
HUB_SUSPENDED_GAME_KILL_CONTROL.y,
|
||||||
|
true,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspended_game_kill_control_truncates_long_titles() {
|
||||||
|
let long_title =
|
||||||
|
SuspendedGameIndicator { title: "Extremely Long Resident Game Title".to_string() };
|
||||||
|
let packet = render_home_packet(0, 0, &[], Some(&long_title));
|
||||||
|
let label = packet
|
||||||
|
.commands
|
||||||
|
.iter()
|
||||||
|
.find_map(|command| match command {
|
||||||
|
GfxUiCommand::DrawText { x, y, text, .. }
|
||||||
|
if *x == HUB_SUSPENDED_GAME_KILL_CONTROL.x + 8
|
||||||
|
&& *y == HUB_SUSPENDED_GAME_KILL_CONTROL.y + 8 =>
|
||||||
|
{
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("kill control text should render");
|
||||||
|
|
||||||
|
assert!(label.ends_with(".."));
|
||||||
|
assert!(label.chars().count() <= ((HUB_SUSPENDED_GAME_KILL_CONTROL.w - 16) / 8) as usize);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[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));
|
||||||
|
|||||||
@ -25,6 +25,29 @@ impl GameLibraryEntry {
|
|||||||
discovered_at,
|
discovered_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn switch_target(&self) -> GameSwitchTarget {
|
||||||
|
GameSwitchTarget::from_entry(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GameSwitchTarget {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub app_id: u32,
|
||||||
|
pub title: String,
|
||||||
|
pub app_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameSwitchTarget {
|
||||||
|
pub fn from_entry(entry: &GameLibraryEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
app_id: entry.app_id,
|
||||||
|
title: entry.title.clone(),
|
||||||
|
app_version: entry.app_version.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@ -256,6 +279,31 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_target_preserves_resolved_identity() {
|
||||||
|
let path = PathBuf::from("test-cartridges/stress-console");
|
||||||
|
let entry = GameLibraryEntry::new(
|
||||||
|
path.clone(),
|
||||||
|
CartridgeManifest {
|
||||||
|
magic: "PMTU".to_string(),
|
||||||
|
cartridge_version: 1,
|
||||||
|
app_id: 42,
|
||||||
|
title: "Stress".to_string(),
|
||||||
|
app_version: "1.0.0".to_string(),
|
||||||
|
app_mode: AppMode::Game,
|
||||||
|
capabilities: vec![],
|
||||||
|
},
|
||||||
|
UNIX_EPOCH,
|
||||||
|
);
|
||||||
|
|
||||||
|
let target = entry.switch_target();
|
||||||
|
|
||||||
|
assert_eq!(target.path, path);
|
||||||
|
assert_eq!(target.app_id, 42);
|
||||||
|
assert_eq!(target.title, "Stress");
|
||||||
|
assert_eq!(target.app_version, "1.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_root_returns_diagnostic_and_no_entries() {
|
fn missing_root_returns_diagnostic_and_no_entries() {
|
||||||
let root = TestRoot::new();
|
let root = TestRoot::new();
|
||||||
@ -267,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")
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -121,6 +121,10 @@ impl VmSessionRegistry {
|
|||||||
self.get_mut(VmSessionId(task_id))
|
self.get_mut(VmSessionId(task_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn remove_for_task(&mut self, task_id: TaskId) -> Option<VmSession> {
|
||||||
|
self.sessions.remove(&VmSessionId(task_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resident_game(&self) -> Option<&VmSession> {
|
pub fn resident_game(&self) -> Option<&VmSession> {
|
||||||
self.sessions.values().find(|session| session.app_mode == AppMode::Game)
|
self.sessions.values().find(|session| session.app_mode == AppMode::Game)
|
||||||
}
|
}
|
||||||
@ -231,6 +235,31 @@ mod tests {
|
|||||||
assert_eq!(registry.resident_game_for_app(43).map(|session| session.task_id), None);
|
assert_eq!(registry.resident_game_for_app(43).map(|session| session.task_id), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_for_task_removes_only_matching_session() {
|
||||||
|
let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);
|
||||||
|
let game_task = Task::new(TaskId(7), game_process.id, 42, "Stress", TaskKind::Game);
|
||||||
|
let shell_process = Process::new(ProcessId(2), 9, "Shell", ProcessKind::VmShell);
|
||||||
|
let shell_task = Task::new(TaskId(8), shell_process.id, 9, "Shell", TaskKind::Shell);
|
||||||
|
let mut registry = VmSessionRegistry::new();
|
||||||
|
|
||||||
|
registry
|
||||||
|
.create_for_task(&game_task, &game_process, "", None, Arc::new(AtomicU32::new(0)))
|
||||||
|
.expect("game session should create");
|
||||||
|
registry
|
||||||
|
.create_for_task(&shell_task, &shell_process, "", None, Arc::new(AtomicU32::new(0)))
|
||||||
|
.expect("shell session should create");
|
||||||
|
|
||||||
|
let removed = registry.remove_for_task(game_task.id).expect("game session should remove");
|
||||||
|
|
||||||
|
assert_eq!(removed.task_id, game_task.id);
|
||||||
|
assert_eq!(registry.for_task(game_task.id).map(|session| session.task_id), None);
|
||||||
|
assert_eq!(
|
||||||
|
registry.for_task(shell_task.id).map(|session| session.task_id),
|
||||||
|
Some(shell_task.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_runtime_state_is_independent_per_session() {
|
fn session_runtime_state_is_independent_per_session() {
|
||||||
let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);
|
let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);
|
||||||
|
|||||||
@ -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":39,"PLN":157,"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":"open","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-03","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"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,145 +0,0 @@
|
|||||||
---
|
|
||||||
id: AGD-0044
|
|
||||||
ticket: system-os-cartridge-switch-orchestrator
|
|
||||||
title: SystemOS Cartridge Switch Orchestrator
|
|
||||||
status: open
|
|
||||||
created: 2026-07-03
|
|
||||||
resolved:
|
|
||||||
decision:
|
|
||||||
tags: [runtime, os, lifecycle, game, cartridge, architecture]
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
A `AGD-0041` cobre o caso `Game -> Home/Shell -> mesmo Game`: o usuario sai do
|
|
||||||
foreground do jogo, o SO assume a tela, Shell/Hub pode rodar, e depois o mesmo
|
|
||||||
Game e retomado.
|
|
||||||
|
|
||||||
Existe um segundo caso, diferente, que nao deve ser escondido dentro da
|
|
||||||
`AGD-0041`: `Game A -> Home -> Game B`.
|
|
||||||
|
|
||||||
Nesse fluxo, o usuario esta com um Game rodando, aperta Home, volta para o SO e
|
|
||||||
pede para iniciar outro jogo. O SystemOS nao deve simplesmente chamar um loader
|
|
||||||
por cima do estado atual. Ele precisa orquestrar a substituicao do app corrente:
|
|
||||||
fechar ou descartar o Game residente, limpar estado cartridge-scoped, resolver o
|
|
||||||
novo alvo, carregar o proximo cartucho e entrar no novo estado de firmware.
|
|
||||||
|
|
||||||
Essa agenda tambem se relaciona com a `AGD-0003 / system-run-cart`: `run_cart`,
|
|
||||||
se existir, nao e userland e nao e o orquestrador. O carregador de cartucho e
|
|
||||||
apenas uma etapa dentro de uma transicao comandada pelo SystemOS.
|
|
||||||
|
|
||||||
## Problema
|
|
||||||
|
|
||||||
Sem um orquestrador explicito de troca de cartucho no SystemOS, a plataforma
|
|
||||||
pode cair em estados incoerentes:
|
|
||||||
|
|
||||||
- carregar um novo Game sem fechar corretamente o Game anterior;
|
|
||||||
- reaproveitar estado VM, assets, input, audio, render ou telemetria do cartucho
|
|
||||||
anterior;
|
|
||||||
- misturar boot direto por linha de comando com troca de app sob o SO;
|
|
||||||
- deixar o loader decidir lifecycle, quando ele deveria apenas materializar o
|
|
||||||
cartucho alvo;
|
|
||||||
- tentar implementar `Home -> outro Game` antes de existir o contrato de
|
|
||||||
foreground/pausa definido pela `AGD-0041`.
|
|
||||||
|
|
||||||
## Pontos Criticos
|
|
||||||
|
|
||||||
- Esta agenda deve ser executada depois da `AGD-0041`.
|
|
||||||
- `Game -> Home -> mesmo Game` fica fora desta agenda.
|
|
||||||
- O SystemOS deve ser a autoridade da troca entre cartuchos sob o SO.
|
|
||||||
- O loader nao deve possuir politica de fechamento, pausa, foreground ou
|
|
||||||
cleanup.
|
|
||||||
- Boot direto por CLI/debug continua sendo caminho separado e mais simples.
|
|
||||||
- A troca para outro Game deve tratar falha de load sem deixar o sistema em
|
|
||||||
estado parcialmente substituido.
|
|
||||||
- `.pmc`, catalogo completo e UX final de launcher continuam fora de escopo,
|
|
||||||
salvo se forem necessarios para definir o contrato minimo.
|
|
||||||
|
|
||||||
## Opcoes
|
|
||||||
|
|
||||||
### Opcao A - Adiar tudo para depois da AGD-0041
|
|
||||||
|
|
||||||
- **Abordagem:** manter esta agenda como registro de escopo futuro; primeiro
|
|
||||||
fechar e implementar o contrato `Game -> Home -> mesmo Game`.
|
|
||||||
- **Pro:** evita misturar pausa/resume com substituicao de cartucho; reduz risco
|
|
||||||
de reabrir arquitetura durante a `AGD-0041`.
|
|
||||||
- **Contra:** a plataforma continua sem troca `Home -> outro Game` ate uma etapa
|
|
||||||
posterior.
|
|
||||||
- **Manutenibilidade:** alta, porque cada agenda fecha um eixo de lifecycle.
|
|
||||||
|
|
||||||
### Opcao B - Definir agora o contrato conceitual, implementar depois
|
|
||||||
|
|
||||||
- **Abordagem:** fechar uma decisao curta ja dizendo que o orquestrador pertence
|
|
||||||
ao SystemOS, que `run_cart` nao e userland, e que a execucao depende da
|
|
||||||
`AGD-0041`; o plano de codigo fica para depois.
|
|
||||||
- **Pro:** reduz ambiguidade enquanto trabalhamos na `AGD-0041`.
|
|
||||||
- **Contra:** pode produzir uma decisao com pouca evidencia operacional antes
|
|
||||||
do contrato de foreground estar fechado.
|
|
||||||
- **Manutenibilidade:** boa se a decisao for claramente marcada como dependente
|
|
||||||
da `AGD-0041`.
|
|
||||||
|
|
||||||
### Opcao C - Implementar o orquestrador completo agora
|
|
||||||
|
|
||||||
- **Abordagem:** tentar resolver `Game A -> Home -> Game B` antes ou junto da
|
|
||||||
`AGD-0041`.
|
|
||||||
- **Pro:** ataca o fluxo final diretamente.
|
|
||||||
- **Contra:** mistura pausa/resume, fechamento, cleanup, resolucao de alvo e
|
|
||||||
falha de load em uma unica frente; alto risco de contrato instavel.
|
|
||||||
- **Manutenibilidade:** baixa para v1, porque a troca completa depende do
|
|
||||||
contrato de foreground ainda aberto.
|
|
||||||
|
|
||||||
## Sugestao / Recomendacao
|
|
||||||
|
|
||||||
Recomendo a Opcao A agora, com esta agenda aberta como marcador explicito de
|
|
||||||
escopo futuro.
|
|
||||||
|
|
||||||
A ordem preferida passa a ser:
|
|
||||||
|
|
||||||
1. fechar a `AGD-0041` para definir foreground, pausa/suspensao, Shell no
|
|
||||||
foreground e retorno ao mesmo Game;
|
|
||||||
2. voltar para esta agenda e decidir o contrato do orquestrador de troca de
|
|
||||||
cartucho no SystemOS;
|
|
||||||
3. so entao planejar a execucao de `Game A -> Home -> Game B`.
|
|
||||||
|
|
||||||
Se durante a `AGD-0041` aparecer uma decisao que exige antecipar parte do
|
|
||||||
orquestrador, esta agenda deve ser reaberta/atualizada com a dependencia
|
|
||||||
concreta em vez de esconder a troca de cartucho dentro da agenda de foreground.
|
|
||||||
|
|
||||||
## Perguntas em Aberto
|
|
||||||
|
|
||||||
- [ ] Depois da `AGD-0041`, qual estado representa um Game que sera descartado
|
|
||||||
em vez de retomado?
|
|
||||||
- [ ] O fechamento do Game atual e observavel para o firmware/game, ou e uma
|
|
||||||
destruicao interna do SystemOS?
|
|
||||||
- [ ] Quais estados cartridge-scoped devem ser limpos antes do novo load: VM,
|
|
||||||
assets, FS handles, input, audio, render ownership, telemetry, logs?
|
|
||||||
- [ ] Se o novo cartucho falha ao carregar, o sistema volta ao Home, mostra uma
|
|
||||||
tela de erro, ou tenta preservar o Game anterior?
|
|
||||||
- [ ] Como a resolucao do novo alvo e representada sem depender de catalogo
|
|
||||||
completo ou `.pmc`?
|
|
||||||
- [ ] Qual evidencia minima prova `Game A -> Home -> Game B` sem depender de UX
|
|
||||||
final de launcher?
|
|
||||||
|
|
||||||
## Criterio para Encerrar
|
|
||||||
|
|
||||||
A agenda pode virar decisao quando:
|
|
||||||
|
|
||||||
- a `AGD-0041` tiver fechado o contrato de foreground/pausa/resume;
|
|
||||||
- estiver claro que o orquestrador pertence ao SystemOS;
|
|
||||||
- `run_cart`/loader estiver limitado a etapa interna, nao userland;
|
|
||||||
- existir regra para fechar/descartar o Game atual;
|
|
||||||
- existir contrato de limpeza de estado cartridge-scoped;
|
|
||||||
- existir politica para falha de carregamento do novo Game;
|
|
||||||
- houver criterio de teste minimo para `Game A -> Home -> Game B`.
|
|
||||||
|
|
||||||
## Discussao
|
|
||||||
|
|
||||||
- 2026-07-03: Agenda criada a partir da separacao entre `AGD-0041`
|
|
||||||
(`Game -> Home -> mesmo Game`) e `AGD-0003` (`run_cart`/loader nao userland).
|
|
||||||
O novo escopo cobre a troca posterior `Game A -> Home -> Game B`, com
|
|
||||||
orquestracao pelo SystemOS depois que o contrato de foreground estiver
|
|
||||||
fechado.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
Ainda em aberto.
|
|
||||||
@ -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