Compare commits
No commits in common. "3cce8a2b25087bbdcc8556e1d1d12d2dec2cfb65" and "313b896e7baecaea5c9a2a2b6d25dd419b653cbb" have entirely different histories.
3cce8a2b25
...
313b896e7b
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -1469,16 +1469,6 @@ 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,7 +8,6 @@ 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,9 +1858,6 @@ 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 {
|
||||||
@ -2671,51 +2668,6 @@ 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,8 +24,6 @@ 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.
|
||||||
@ -40,8 +38,6 @@ 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.
|
||||||
@ -56,8 +52,6 @@ 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.
|
||||||
@ -106,10 +100,6 @@ 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 {
|
||||||
@ -130,10 +120,6 @@ 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 {
|
||||||
@ -154,10 +140,6 @@ 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,24 +250,21 @@ 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::{GlyphBankPoolAccess, SceneBankPoolAccess, TestPlatform};
|
use prometeu_drivers::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, HUB_SUSPENDED_GAME_KILL_CONTROL,
|
CrashReport, ForegroundOwner, GameLifecycleEventKind, ResidentGameState,
|
||||||
ResidentGameState, discover_games_root,
|
discover_games_root,
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -292,45 +289,20 @@ 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 = format!(
|
let manifest = r#"{
|
||||||
r#"{{
|
"magic": "PMTU",
|
||||||
"magic": "PMTU",
|
"cartridge_version": 1,
|
||||||
"cartridge_version": 1,
|
"app_id": 42,
|
||||||
"app_id": {app_id},
|
"title": "Stress",
|
||||||
"title": "{title}",
|
"app_version": "1.0.0",
|
||||||
"app_version": "{app_version}",
|
"app_mode": "Game"
|
||||||
"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 let Some(program) = program {
|
if with_program {
|
||||||
fs::write(cart.join("program.pbx"), program)
|
fs::write(cart.join("program.pbx"), halting_program())
|
||||||
.expect("test program should be written");
|
.expect("test program should be written");
|
||||||
}
|
}
|
||||||
cart
|
cart
|
||||||
@ -361,10 +333,6 @@ 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,
|
||||||
@ -479,12 +447,6 @@ 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);
|
||||||
@ -766,499 +728,6 @@ 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();
|
||||||
@ -1441,8 +910,6 @@ 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();
|
||||||
|
|
||||||
@ -1474,6 +941,7 @@ 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),
|
||||||
@ -1502,8 +970,6 @@ 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(_)));
|
||||||
@ -1516,6 +982,7 @@ 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,7 +2,6 @@ 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};
|
||||||
@ -53,71 +52,45 @@ 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 { target }) => {
|
Some(SystemProfileAction::LaunchGame { path }) => match CartridgeLoader::load(&path) {
|
||||||
match CartridgeLoader::load(&target.path) {
|
Ok(cartridge) => {
|
||||||
Ok(cartridge) => {
|
if cartridge.app_mode == AppMode::Game
|
||||||
if cartridge.app_mode == AppMode::Game
|
&& let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task()
|
||||||
&& let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task()
|
{
|
||||||
{
|
let resident_app_id = ctx
|
||||||
let resident_app_id = ctx
|
.os
|
||||||
.os
|
.sessions()
|
||||||
.sessions()
|
.vm_session_for_task(resident_task_id)
|
||||||
.vm_session_for_task(resident_task_id)
|
.map(|session| session.app_id)
|
||||||
.map(|session| session.app_id)
|
.unwrap_or(0);
|
||||||
.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::Error,
|
LogLevel::Warn,
|
||||||
LogSource::Hub,
|
LogSource::Hub,
|
||||||
0,
|
0,
|
||||||
format!(
|
format!(
|
||||||
"Failed to launch Home game {}: {:?}",
|
"Cannot launch Home game {} while resident game app_id {} is active",
|
||||||
target.path.display(),
|
cartridge.title, resident_app_id
|
||||||
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 kill resident game from Hub: {error:?}"),
|
format!("Failed to launch Home game {}: {:?}", path.display(), error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Some(SystemProfileAction::CloseShell) | None => {}
|
Some(SystemProfileAction::CloseShell) | None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
use crate::firmware::firmware_state::{
|
use crate::firmware::firmware_state::{
|
||||||
AppCrashesStep, FirmwareState, GameRunningStep, HubHomeStep, ShellRunningStep,
|
AppCrashesStep, FirmwareState, GameRunningStep, 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;
|
||||||
@ -17,28 +16,11 @@ 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 {
|
Self { cartridge, init_error: None, loaded_task_id: None, loaded_app_mode: None }
|
||||||
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) {
|
||||||
@ -49,8 +31,6 @@ 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(),
|
||||||
@ -71,38 +51,6 @@ 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,7 +12,6 @@ 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;
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
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,10 +8,7 @@ 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::{
|
pub use programs::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
|
||||||
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,
|
||||||
@ -23,7 +20,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, GameSwitchTarget, discover_games_root,
|
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root,
|
||||||
};
|
};
|
||||||
pub use services::memcard::MemcardAsyncLaneOperation;
|
pub use services::memcard::MemcardAsyncLaneOperation;
|
||||||
pub use services::process;
|
pub use services::process;
|
||||||
|
|||||||
@ -222,28 +222,6 @@ 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,12 +250,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,8 +317,4 @@ 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,42 +218,6 @@ 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,6 +1,3 @@
|
|||||||
mod prometeu_hub;
|
mod prometeu_hub;
|
||||||
|
|
||||||
pub use prometeu_hub::{
|
pub use prometeu_hub::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
|
||||||
HUB_SUSPENDED_GAME_KILL_CONTROL, NativeShellApp, PrometeuHub, SystemProfileAction,
|
|
||||||
SystemProfileUpdate,
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
#[allow(clippy::module_inception)]
|
#[allow(clippy::module_inception)]
|
||||||
mod prometeu_hub;
|
mod prometeu_hub;
|
||||||
|
|
||||||
pub use prometeu_hub::{
|
pub use prometeu_hub::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
|
||||||
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, GameSwitchTarget, SystemOS};
|
use crate::{CrashReport, GameLibrary, 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,7 +20,6 @@ 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 };
|
||||||
|
|
||||||
@ -33,8 +32,6 @@ 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 {
|
||||||
@ -86,8 +83,7 @@ impl NativeShellApp {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum SystemProfileAction {
|
pub enum SystemProfileAction {
|
||||||
LaunchNativeShell(NativeShellApp),
|
LaunchNativeShell(NativeShellApp),
|
||||||
LaunchGame { target: GameSwitchTarget },
|
LaunchGame { path: PathBuf },
|
||||||
KillResidentGame,
|
|
||||||
CloseShell,
|
CloseShell,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,14 +132,12 @@ 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);
|
||||||
}
|
}
|
||||||
@ -156,13 +150,7 @@ 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 {
|
||||||
let suspended_game = resident_game_indicator(os);
|
render_home_packet(pointer_x, pointer_y, self.game_library.entries())
|
||||||
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() {
|
||||||
@ -245,7 +233,6 @@ 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;
|
||||||
@ -255,10 +242,6 @@ 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)
|
||||||
@ -266,7 +249,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 { target: entry.switch_target() });
|
return Some(SystemProfileAction::LaunchGame { path: entry.path.clone() });
|
||||||
}
|
}
|
||||||
|
|
||||||
HOME_BUTTONS
|
HOME_BUTTONS
|
||||||
@ -275,23 +258,6 @@ 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,
|
||||||
@ -316,7 +282,6 @@ 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 });
|
||||||
@ -356,11 +321,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -463,39 +423,6 @@ 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 });
|
||||||
}
|
}
|
||||||
@ -507,7 +434,6 @@ 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,
|
||||||
@ -536,14 +462,7 @@ 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(
|
action_for_click(false, HOME_BUTTONS[0].rect.x, HOME_BUTTONS[0].rect.y, true, &[]),
|
||||||
false,
|
|
||||||
HOME_BUTTONS[0].rect.x,
|
|
||||||
HOME_BUTTONS[0].rect.y,
|
|
||||||
true,
|
|
||||||
&[],
|
|
||||||
None
|
|
||||||
),
|
|
||||||
Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellA))
|
Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellA))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -556,8 +475,7 @@ 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))
|
||||||
);
|
);
|
||||||
@ -571,8 +489,7 @@ 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
|
||||||
);
|
);
|
||||||
@ -582,8 +499,7 @@ 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
|
||||||
);
|
);
|
||||||
@ -591,10 +507,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn held_pointer_does_not_emit_click_action() {
|
fn held_pointer_does_not_emit_click_action() {
|
||||||
assert_eq!(
|
assert_eq!(action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[]), None);
|
||||||
action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[], None),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -622,35 +535,19 @@ 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,
|
action_for_click(false, GAME_ROW_X, GAME_ROW_Y, true, std::slice::from_ref(&entry)),
|
||||||
Some(SystemProfileAction::LaunchGame {
|
Some(SystemProfileAction::LaunchGame { path: entry.path.clone() })
|
||||||
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, &[], None),
|
action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]),
|
||||||
Some(SystemProfileAction::CloseShell)
|
Some(SystemProfileAction::CloseShell)
|
||||||
);
|
);
|
||||||
assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[], None), None);
|
assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -664,7 +561,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, &[], None);
|
let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[]);
|
||||||
|
|
||||||
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| {
|
||||||
@ -672,123 +569,6 @@ 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,29 +25,6 @@ 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)]
|
||||||
@ -279,31 +256,6 @@ 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();
|
||||||
@ -315,22 +267,4 @@ 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,10 +121,6 @@ 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)
|
||||||
}
|
}
|
||||||
@ -235,31 +231,6 @@ 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,22 +26,16 @@ const IDLE_HOST_POLL_DT: Duration = Duration::from_millis(100);
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct PresentationState {
|
struct PresentationState {
|
||||||
latest_published_frame: Option<PresentationFrameMarker>,
|
latest_published_frame: u64,
|
||||||
last_presented_frame: Option<PresentationFrameMarker>,
|
last_presented_frame: Option<u64>,
|
||||||
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: None,
|
latest_published_frame: 0,
|
||||||
last_presented_frame: None,
|
last_presented_frame: None,
|
||||||
host_invalidated: true,
|
host_invalidated: true,
|
||||||
redraw_requested: false,
|
redraw_requested: false,
|
||||||
@ -50,10 +44,9 @@ impl Default for PresentationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PresentationState {
|
impl PresentationState {
|
||||||
fn note_published_frame(&mut self, ownership: RenderOwnership, frame_id: u64) {
|
fn note_published_frame(&mut self, frame_index: u64) {
|
||||||
let marker = PresentationFrameMarker { ownership, frame_id };
|
if frame_index > self.latest_published_frame {
|
||||||
if self.latest_published_frame != Some(marker) {
|
self.latest_published_frame = frame_index;
|
||||||
self.latest_published_frame = Some(marker);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +55,7 @@ impl PresentationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn needs_redraw(&self) -> bool {
|
fn needs_redraw(&self) -> bool {
|
||||||
self.host_invalidated || self.last_presented_frame != self.latest_published_frame
|
self.host_invalidated || self.last_presented_frame != Some(self.latest_published_frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_request_redraw(&mut self) -> bool {
|
fn should_request_redraw(&mut self) -> bool {
|
||||||
@ -77,7 +70,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 = self.latest_published_frame;
|
self.last_presented_frame = Some(self.latest_published_frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,11 +437,9 @@ 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
|
self.presentation.note_published_frame(worker_frame.frame_id.get());
|
||||||
.note_published_frame(worker_frame.ownership, worker_frame.frame_id.get());
|
|
||||||
} else {
|
} else {
|
||||||
self.presentation
|
self.presentation.note_published_frame(self.firmware.os.frame_index());
|
||||||
.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()
|
||||||
@ -514,37 +505,14 @@ 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(ownership, 1);
|
state.note_published_frame(1);
|
||||||
|
|
||||||
assert!(state.should_request_redraw());
|
assert!(state.should_request_redraw());
|
||||||
state.mark_presented();
|
state.mark_presented();
|
||||||
assert_eq!(
|
assert_eq!(state.last_presented_frame, Some(1));
|
||||||
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]
|
||||||
@ -640,14 +608,11 @@ 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.ownership, published.frame_id.get());
|
presentation.note_published_frame(published.frame_id.get());
|
||||||
|
|
||||||
assert!(presentation.should_request_redraw());
|
assert!(presentation.should_request_redraw());
|
||||||
presentation.mark_presented();
|
presentation.mark_presented();
|
||||||
assert_eq!(
|
assert_eq!(presentation.last_presented_frame, Some(9));
|
||||||
presentation.last_presented_frame,
|
|
||||||
Some(PresentationFrameMarker { ownership: published.ownership, frame_id: 9 })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
[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"
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
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"]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
pbxgen_dummy_boy::generate()
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
{"type":"meta","next_id":{"DSC":45,"AGD":48,"DEC":41,"PLN":167,"LSN":55,"CLSN":1}}
|
{"type":"meta","next_id":{"DSC":44,"AGD":47,"DEC":39,"PLN":157,"LSN":53,"CLSN":1}}
|
||||||
{"type":"discussion","id":"DSC-0044","status":"done","ticket":"hub-suspended-game-kill-affordance","title":"Hub Suspended Game Kill Affordance","created_at":"2026-07-05","updated_at":"2026-07-05","tags":["hub","lifecycle","game","ui"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0054","file":"discussion/lessons/DSC-0044-hub-suspended-game-kill-affordance/LSN-0054-manual-hub-kill-must-share-game-termination-cleanup.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]}
|
{"type":"discussion","id":"DSC-0043","status":"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-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"}]}
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
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,11 +128,6 @@ 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
|
||||||
@ -168,20 +163,6 @@ 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,11 +44,6 @@ 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,9 +43,6 @@ 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
|
||||||
@ -55,8 +52,6 @@ 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
|
||||||
|
|
||||||
@ -91,8 +86,6 @@ 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.
|
||||||
|
|
||||||
@ -223,17 +216,6 @@ 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:
|
||||||
@ -249,51 +231,11 @@ 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 from
|
resident Game from Home resumes that session. Selecting a different Game while a
|
||||||
Home is a SystemOS-owned Game switch and MUST be destructive replacement:
|
resident Game exists is outside the v1 foreground contract and must not create a
|
||||||
|
second resident Game session. VM-backed Shell loading creates a distinct Shell
|
||||||
```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
|
||||||
@ -306,16 +248,6 @@ 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:
|
||||||
@ -378,9 +310,6 @@ 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,9 +186,6 @@ 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.
|
||||||
@ -196,11 +193,6 @@ 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,11 +97,9 @@ 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 a boot profile.
|
Game-to-game switching after a game is already active is not part of this boot
|
||||||
That orchestration belongs to the foreground/home and cartridge switch
|
profile. That orchestration belongs to the foreground/home and cartridge switch
|
||||||
contracts. In that path, SystemOS owns the switch policy: selecting the same
|
contracts.
|
||||||
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
|
||||||
@ -110,10 +108,6 @@ 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
|
||||||
|
|
||||||
@ -158,10 +152,6 @@ 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.
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"magic": "PMTU",
|
|
||||||
"cartridge_version": 1,
|
|
||||||
"app_id": 2,
|
|
||||||
"title": "Dummy Boy",
|
|
||||||
"app_version": "0.1.0",
|
|
||||||
"app_mode": "Game",
|
|
||||||
"capabilities": ["gfx2d"]
|
|
||||||
}
|
|
||||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user