961 lines
37 KiB
Rust
961 lines
37 KiB
Rust
use crate::firmware::boot_target::BootTarget;
|
|
use crate::firmware::firmware_state::{
|
|
FirmwareState, GameRunningStep, LoadCartridgeStep, ResetStep,
|
|
};
|
|
use crate::firmware::prometeu_context::PrometeuContext;
|
|
use prometeu_hal::app_mode::AppMode;
|
|
use prometeu_hal::cartridge::Cartridge;
|
|
use prometeu_hal::log::LogSource;
|
|
use prometeu_hal::telemetry::CertificationConfig;
|
|
use prometeu_hal::{InputSignals, RuntimePlatform};
|
|
use prometeu_system::{PrometeuHub, ResidentGameState, SystemOS};
|
|
use prometeu_vm::VirtualMachine;
|
|
|
|
/// PROMETEU Firmware.
|
|
///
|
|
/// The central orchestrator of the console. The firmware acts as the
|
|
/// "Control Unit", managing the high-level state machine of the system.
|
|
///
|
|
/// It is responsible for transitioning between different modes of operation,
|
|
/// such as showing the splash screen, running the Hub (launcher), or
|
|
/// executing a game/app.
|
|
///
|
|
/// ### Execution Loop:
|
|
/// The firmware is designed to be ticked once per frame (60Hz). During each
|
|
/// tick, it:
|
|
/// 1. Updates peripherals with the latest input signals.
|
|
/// 2. Delegates the logic update to the current active state.
|
|
/// 3. Handles state transitions (e.g., from Loading to Playing).
|
|
pub struct Firmware {
|
|
/// The execution engine (PVM) for user applications.
|
|
pub vm: VirtualMachine,
|
|
/// The underlying OS services (Syscalls, Filesystem, Telemetry).
|
|
pub os: SystemOS,
|
|
/// The internal state of the system launcher (Hub).
|
|
pub hub: PrometeuHub,
|
|
/// The current operational state (e.g., Reset, SplashScreen, GameRunning).
|
|
pub state: FirmwareState,
|
|
/// The desired application to run after boot (Hub or specific Cartridge).
|
|
pub boot_target: BootTarget,
|
|
/// State-machine lifecycle tracker.
|
|
state_initialized: bool,
|
|
game_input_barrier_frames: u8,
|
|
}
|
|
|
|
impl Firmware {
|
|
/// Initializes the firmware in the `Reset` state.
|
|
pub fn new(cap_config: Option<CertificationConfig>) -> Self {
|
|
Self {
|
|
vm: VirtualMachine::default(),
|
|
os: SystemOS::new(cap_config),
|
|
hub: PrometeuHub::new(),
|
|
state: FirmwareState::Reset(ResetStep),
|
|
boot_target: BootTarget::Hub,
|
|
state_initialized: false,
|
|
game_input_barrier_frames: 0,
|
|
}
|
|
}
|
|
|
|
/// The main entry point for the Host to advance the system logic.
|
|
///
|
|
/// This method is called exactly once per Host frame (60Hz).
|
|
/// It updates peripheral signals and delegates the logic to the current state.
|
|
pub fn tick(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
|
let barriered_signals = InputSignals::default();
|
|
let effective_signals = if self.game_input_barrier_frames > 0 {
|
|
self.game_input_barrier_frames -= 1;
|
|
&barriered_signals
|
|
} else {
|
|
signals
|
|
};
|
|
|
|
// 0. Process asset commits at the beginning of the frame boundary.
|
|
platform.assets_mut().apply_commits();
|
|
|
|
// 1. Update the peripheral state using the latest signals from the Host.
|
|
// This ensures input is consistent throughout the entire update.
|
|
platform.input_mut().pad_mut().begin_frame(effective_signals);
|
|
platform.input_mut().touch_mut().begin_frame(effective_signals);
|
|
|
|
// 2. State machine lifecycle management.
|
|
if !self.state_initialized {
|
|
self.on_enter(effective_signals, platform);
|
|
self.state_initialized = true;
|
|
}
|
|
|
|
// 3. Update the current state and check for transitions.
|
|
if let Some(next_state) = self.on_update(effective_signals, platform) {
|
|
self.change_state(next_state, effective_signals, platform);
|
|
}
|
|
}
|
|
|
|
/// Transitions the system to a new state, handling lifecycle hooks.
|
|
pub fn change_state(
|
|
&mut self,
|
|
new_state: FirmwareState,
|
|
signals: &InputSignals,
|
|
platform: &mut dyn RuntimePlatform,
|
|
) {
|
|
self.on_exit(signals, platform);
|
|
self.state = new_state;
|
|
self.state_initialized = false;
|
|
|
|
// Enter the new state immediately to avoid "empty" frames during transitions.
|
|
self.on_enter(signals, platform);
|
|
self.state_initialized = true;
|
|
}
|
|
|
|
/// Dispatches the `on_enter` event to the current state implementation.
|
|
fn on_enter(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
|
let mut req = PrometeuContext {
|
|
vm: &mut self.vm,
|
|
os: &mut self.os,
|
|
hub: &mut self.hub,
|
|
boot_target: &self.boot_target,
|
|
signals,
|
|
platform,
|
|
};
|
|
match &mut self.state {
|
|
FirmwareState::Reset(s) => s.on_enter(&mut req),
|
|
FirmwareState::SplashScreen(s) => s.on_enter(&mut req),
|
|
FirmwareState::LaunchHub(s) => s.on_enter(&mut req),
|
|
FirmwareState::HubHome(s) => s.on_enter(&mut req),
|
|
FirmwareState::LoadCartridge(s) => s.on_enter(&mut req),
|
|
FirmwareState::GameRunning(s) => s.on_enter(&mut req),
|
|
FirmwareState::ShellRunning(s) => s.on_enter(&mut req),
|
|
FirmwareState::AppCrashes(s) => s.on_enter(&mut req),
|
|
}
|
|
}
|
|
|
|
/// Dispatches the `on_update` event to the current state implementation.
|
|
/// Returns an optional `FirmwareState` if a transition is requested.
|
|
fn on_update(
|
|
&mut self,
|
|
signals: &InputSignals,
|
|
platform: &mut dyn RuntimePlatform,
|
|
) -> Option<FirmwareState> {
|
|
let mut req = PrometeuContext {
|
|
vm: &mut self.vm,
|
|
os: &mut self.os,
|
|
hub: &mut self.hub,
|
|
boot_target: &self.boot_target,
|
|
signals,
|
|
platform,
|
|
};
|
|
match &mut self.state {
|
|
FirmwareState::Reset(s) => s.on_update(&mut req),
|
|
FirmwareState::SplashScreen(s) => s.on_update(&mut req),
|
|
FirmwareState::LaunchHub(s) => s.on_update(&mut req),
|
|
FirmwareState::HubHome(s) => s.on_update(&mut req),
|
|
FirmwareState::LoadCartridge(s) => s.on_update(&mut req),
|
|
FirmwareState::GameRunning(s) => s.on_update(&mut req),
|
|
FirmwareState::ShellRunning(s) => s.on_update(&mut req),
|
|
FirmwareState::AppCrashes(s) => s.on_update(&mut req),
|
|
}
|
|
}
|
|
|
|
/// Dispatches the `on_exit` event to the current state implementation.
|
|
fn on_exit(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
|
let mut req = PrometeuContext {
|
|
vm: &mut self.vm,
|
|
os: &mut self.os,
|
|
hub: &mut self.hub,
|
|
boot_target: &self.boot_target,
|
|
signals,
|
|
platform,
|
|
};
|
|
match &mut self.state {
|
|
FirmwareState::Reset(s) => s.on_exit(&mut req),
|
|
FirmwareState::SplashScreen(s) => s.on_exit(&mut req),
|
|
FirmwareState::LaunchHub(s) => s.on_exit(&mut req),
|
|
FirmwareState::HubHome(s) => s.on_exit(&mut req),
|
|
FirmwareState::LoadCartridge(s) => s.on_exit(&mut req),
|
|
FirmwareState::GameRunning(s) => s.on_exit(&mut req),
|
|
FirmwareState::ShellRunning(s) => s.on_exit(&mut req),
|
|
FirmwareState::AppCrashes(s) => s.on_exit(&mut req),
|
|
}
|
|
}
|
|
|
|
pub fn load_cartridge(&mut self, cartridge: Cartridge) {
|
|
self.state = FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge));
|
|
self.state_initialized = false;
|
|
}
|
|
|
|
pub fn request_home_from_host(&mut self) {
|
|
self.os.info(LogSource::Pos, "Host requested Home/SystemOS".to_string());
|
|
if let FirmwareState::GameRunning(step) = &self.state {
|
|
if let Err(error) = self.os.lifecycle().request_home_from_game(step.task_id) {
|
|
self.os.warn(
|
|
LogSource::Pos,
|
|
format!("Home/SystemOS request could not pause Game: {error:?}"),
|
|
);
|
|
} else {
|
|
self.game_input_barrier_frames = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn resume_resident_game_from_home(&mut self) -> bool {
|
|
let Some(task_id) = self.os.lifecycle().resident_game_task() else {
|
|
return false;
|
|
};
|
|
|
|
if self.os.lifecycle().resume_task(task_id).is_err() {
|
|
return false;
|
|
}
|
|
|
|
let app_id = self.os.vm().current_app_id();
|
|
self.os.vm().transition_render_owner(AppMode::Game, app_id);
|
|
self.state = FirmwareState::GameRunning(GameRunningStep::new(task_id));
|
|
self.state_initialized = false;
|
|
self.game_input_barrier_frames = 1;
|
|
true
|
|
}
|
|
|
|
pub fn game_lifecycle_audio_paused(&mut self) -> bool {
|
|
matches!(
|
|
self.os.lifecycle().resident_game().map(|game| game.state),
|
|
Some(
|
|
ResidentGameState::PauseRequested { .. }
|
|
| ResidentGameState::PausedSuspended
|
|
| ResidentGameState::ResumeRequested,
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::firmware::firmware_state::HubHomeStep;
|
|
use prometeu_bytecode::assembler::assemble;
|
|
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
|
|
use prometeu_drivers::TestPlatform;
|
|
use prometeu_hal::RuntimePlatform;
|
|
use prometeu_hal::app_mode::AppMode;
|
|
use prometeu_hal::cartridge::AssetsPayloadSource;
|
|
use prometeu_hal::color::Color;
|
|
use prometeu_hal::primitives::Rect;
|
|
use prometeu_hal::syscalls::{Syscall, caps};
|
|
use prometeu_system::process::ProcessState;
|
|
use prometeu_system::task::{TaskId, TaskState};
|
|
use prometeu_system::windows::WindowOwner;
|
|
use prometeu_system::{
|
|
CrashReport, ForegroundOwner, GameLifecycleEventKind, ResidentGameState,
|
|
discover_games_root,
|
|
};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
struct TestGameRoot {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TestGameRoot {
|
|
fn new() -> Self {
|
|
let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"prometeu-firmware-game-root-test-{}-{}",
|
|
std::process::id(),
|
|
unique
|
|
));
|
|
fs::create_dir_all(&path).expect("test game root should be created");
|
|
Self { path }
|
|
}
|
|
|
|
fn add_game(&self, name: &str, with_program: bool) -> PathBuf {
|
|
let cart = self.path.join(name);
|
|
fs::create_dir_all(&cart).expect("test cartridge should be created");
|
|
let manifest = r#"{
|
|
"magic": "PMTU",
|
|
"cartridge_version": 1,
|
|
"app_id": 42,
|
|
"title": "Stress",
|
|
"app_version": "1.0.0",
|
|
"app_mode": "Game"
|
|
}"#;
|
|
fs::write(cart.join("manifest.json"), manifest)
|
|
.expect("test manifest should be written");
|
|
if with_program {
|
|
fs::write(cart.join("program.pbx"), halting_program())
|
|
.expect("test program should be written");
|
|
}
|
|
cart
|
|
}
|
|
}
|
|
|
|
impl Drop for TestGameRoot {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
fn halting_program() -> Vec<u8> {
|
|
let code = assemble("HALT").expect("assemble");
|
|
BytecodeModule {
|
|
version: 0,
|
|
const_pool: vec![],
|
|
functions: vec![FunctionMeta {
|
|
code_offset: 0,
|
|
code_len: code.len() as u32,
|
|
..Default::default()
|
|
}],
|
|
code,
|
|
debug_info: None,
|
|
exports: vec![],
|
|
syscalls: vec![],
|
|
}
|
|
.serialize()
|
|
}
|
|
|
|
fn invalid_game_cartridge() -> Cartridge {
|
|
Cartridge {
|
|
app_id: 7,
|
|
title: "Broken Cart".into(),
|
|
app_version: "1.0.0".into(),
|
|
app_mode: AppMode::Game,
|
|
capabilities: 0,
|
|
program: vec![0, 0, 0, 0],
|
|
assets: AssetsPayloadSource::empty(),
|
|
asset_table: vec![],
|
|
preload: vec![],
|
|
}
|
|
}
|
|
|
|
fn trapping_game_cartridge() -> Cartridge {
|
|
let code = assemble("PUSH_BOOL 1\nHOSTCALL 0\nHALT").expect("assemble");
|
|
let program = BytecodeModule {
|
|
version: 0,
|
|
const_pool: vec![],
|
|
functions: vec![FunctionMeta {
|
|
code_offset: 0,
|
|
code_len: code.len() as u32,
|
|
..Default::default()
|
|
}],
|
|
code,
|
|
debug_info: None,
|
|
exports: vec![],
|
|
syscalls: vec![SyscallDecl {
|
|
module: "gfx2d".into(),
|
|
name: "clear".into(),
|
|
version: 1,
|
|
arg_slots: 1,
|
|
ret_slots: 0,
|
|
}],
|
|
}
|
|
.serialize();
|
|
|
|
Cartridge {
|
|
app_id: 8,
|
|
title: "Trap Cart".into(),
|
|
app_version: "1.0.0".into(),
|
|
app_mode: AppMode::Game,
|
|
capabilities: caps::GFX,
|
|
program,
|
|
assets: AssetsPayloadSource::empty(),
|
|
asset_table: vec![],
|
|
preload: vec![],
|
|
}
|
|
}
|
|
|
|
fn valid_cartridge(app_mode: AppMode) -> Cartridge {
|
|
Cartridge {
|
|
app_id: 9,
|
|
title: "Valid Cart".into(),
|
|
app_version: "1.0.0".into(),
|
|
app_mode,
|
|
capabilities: caps::NONE,
|
|
program: halting_program(),
|
|
assets: AssetsPayloadSource::empty(),
|
|
asset_table: vec![],
|
|
preload: vec![],
|
|
}
|
|
}
|
|
|
|
fn load_shell_running_firmware() -> (Firmware, TestPlatform, InputSignals, TaskId) {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
(firmware, platform, signals, task_id)
|
|
}
|
|
|
|
fn hub_home_firmware() -> (Firmware, TestPlatform) {
|
|
let mut firmware = Firmware::new(None);
|
|
firmware.state = FirmwareState::HubHome(HubHomeStep);
|
|
firmware.state_initialized = false;
|
|
|
|
(firmware, TestPlatform::new())
|
|
}
|
|
|
|
fn focus_window(firmware: &mut Firmware, owner: WindowOwner) {
|
|
let mut windows = firmware.os.windows();
|
|
windows.remove_all_windows();
|
|
let id = windows.add_window(
|
|
"Test Window".to_string(),
|
|
owner,
|
|
Rect { x: 0, y: 0, w: 10, h: 10 },
|
|
Color::WHITE,
|
|
);
|
|
windows.set_focus(id);
|
|
}
|
|
|
|
#[test]
|
|
fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(invalid_game_cartridge());
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::AppCrashes(step) => match &step.report {
|
|
CrashReport::VmInit { error } => {
|
|
assert!(matches!(error, prometeu_vm::VmInitError::InvalidFormat));
|
|
}
|
|
other => panic!("expected VmInit crash report, got {:?}", other),
|
|
},
|
|
other => panic!("expected AppCrashes state, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn game_running_transitions_to_app_crashes_when_runtime_surfaces_trap() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(trapping_game_cartridge());
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::AppCrashes(step) => match &step.report {
|
|
CrashReport::VmTrap { trap } => {
|
|
assert!(trap.message.contains("Expected integer at index 0"));
|
|
}
|
|
other => panic!("expected VmTrap crash report, got {:?}", other),
|
|
},
|
|
other => panic!("expected AppCrashes state, got {:?}", other),
|
|
}
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Crashed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Crashed)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_routes_hub_boot_target_to_splash_screen() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.boot_target = BootTarget::Hub;
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::SplashScreen(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn reset_routes_cartridge_boot_target_to_launch_hub() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.boot_target =
|
|
BootTarget::Cartridge { path: "missing-cart".into(), debug: false, debug_port: 7777 };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::LaunchHub(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn load_cartridge_routes_system_apps_to_system_running() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::ShellRunning(_)));
|
|
assert!(firmware.os.windows().focused_window().is_some());
|
|
assert_eq!(firmware.os.windows().window_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn system_running_ticks_focused_system_app_through_hub_pipeline() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
|
|
firmware.tick(&signals, &mut platform);
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
let tick_index_before_system_update = firmware.os.vm().tick_index();
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::ShellRunning(_)));
|
|
assert!(firmware.os.windows().focused_window_belongs_to_task(task_id));
|
|
assert_eq!(firmware.os.vm().tick_index(), tick_index_before_system_update + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_does_not_launch_shell_before_home_input_is_armed() {
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
let signals = InputSignals { f_signal: true, x_pos: 112, y_pos: 172, ..Default::default() };
|
|
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.windows().window_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_click_launches_shell_a_as_native_shell_task_window() {
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 112, y_pos: 172, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
assert!(firmware.os.windows().focused_window_belongs_to_task(task_id));
|
|
assert_eq!(firmware.os.windows().window_count(), 1);
|
|
assert_eq!(firmware.os.windows().windows()[0].title, "ShellA");
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Foreground));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Running)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_click_launches_shell_b_as_native_shell_task_window() {
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 256, y_pos: 172, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
assert!(firmware.os.windows().focused_window_belongs_to_task(task_id));
|
|
assert_eq!(firmware.os.windows().window_count(), 1);
|
|
assert_eq!(firmware.os.windows().windows()[0].title, "ShellB");
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Foreground));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Running)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_click_launches_game_library_entry_through_load_cartridge() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("stress", 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 signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::LoadCartridge(step) => {
|
|
assert_eq!(step.cartridge.title, "Stress");
|
|
assert_eq!(step.cartridge.app_id, 42);
|
|
}
|
|
other => panic!("expected LoadCartridge state, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn games_root_home_click_reaches_game_running() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("stress", 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 signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress");
|
|
assert_eq!(firmware.os.windows().window_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("broken", false);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
let library = discover_games_root(&root.path);
|
|
assert!(library.is_empty());
|
|
assert!(!library.diagnostics().is_empty());
|
|
firmware.hub.set_game_library(library);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn direct_boot_target_still_reaches_game_running_without_games_root() {
|
|
let root = TestGameRoot::new();
|
|
let cart = root.add_game("stress", true);
|
|
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
firmware.boot_target = BootTarget::Cartridge {
|
|
path: cart.display().to_string(),
|
|
debug: false,
|
|
debug_port: 7777,
|
|
};
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress");
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_failed_game_launch_remains_home_and_logs_error() {
|
|
let root = TestGameRoot::new();
|
|
let cart = root.add_game("stress", true);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
|
fs::remove_file(cart.join("program.pbx")).expect("program should be removable");
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert!(
|
|
firmware
|
|
.os
|
|
.recent_logs(16)
|
|
.iter()
|
|
.any(|entry| entry.msg.contains("Failed to launch Home game"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_start_close_uses_lifecycle_and_returns_hub_home() {
|
|
let (mut firmware, mut platform, _signals, task_id) = load_shell_running_firmware();
|
|
let close_signals = InputSignals { start_signal: true, ..Default::default() };
|
|
|
|
firmware.tick(&close_signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_close_click_uses_lifecycle_and_returns_hub_home() {
|
|
let (mut firmware, mut platform, _signals, task_id) = load_shell_running_firmware();
|
|
let close_signals =
|
|
InputSignals { f_signal: true, x_pos: 420, y_pos: 24, ..Default::default() };
|
|
|
|
firmware.tick(&close_signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_without_focused_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
firmware.os.windows().remove_all_windows();
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_for_focused_hub_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
focus_window(&mut firmware, WindowOwner::Hub);
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_for_focused_overlay_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
focus_window(&mut firmware, WindowOwner::Overlay);
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_for_other_task_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
focus_window(&mut firmware, WindowOwner::Task(TaskId(task_id.0 + 1)));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_cartridge_routes_game_apps_to_game_running() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.windows().window_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn host_home_request_pauses_then_suspends_game_before_next_vm_tick() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
let tick_index_before_home = firmware.os.vm().tick_index();
|
|
let render_ownership_before_home = firmware.os.vm().active_render_ownership();
|
|
|
|
firmware.request_home_from_host();
|
|
|
|
assert!(firmware.game_lifecycle_audio_paused());
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(task_id));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().resident_game().map(|game| game.state),
|
|
Some(ResidentGameState::PauseRequested { remaining_ticks: 1 })
|
|
);
|
|
assert_eq!(
|
|
firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::Pause)
|
|
);
|
|
|
|
let held_signals =
|
|
InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals };
|
|
firmware.tick(&held_signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Suspended));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Suspended)
|
|
);
|
|
assert_eq!(firmware.os.vm().tick_index(), tick_index_before_home);
|
|
assert!(!platform.input().pad().any());
|
|
assert!(!platform.input().touch().f().down);
|
|
|
|
let render_ownership_after_home = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(render_ownership_after_home.app_mode, AppMode::Shell);
|
|
assert_eq!(render_ownership_after_home.app_id, 0);
|
|
assert!(render_ownership_after_home.epoch > render_ownership_before_home.epoch);
|
|
assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty());
|
|
assert_eq!(
|
|
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::Pause)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resident_game_resume_restores_render_owner_after_resume_event_and_barriers_input() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&signals, &mut platform);
|
|
let hub_ownership = firmware.os.vm().active_render_ownership();
|
|
|
|
assert!(firmware.resume_resident_game_from_home());
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert!(!firmware.game_lifecycle_audio_paused());
|
|
assert_eq!(
|
|
firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
|
|
let game_ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(game_ownership.app_mode, AppMode::Game);
|
|
assert_eq!(game_ownership.app_id, 9);
|
|
assert!(game_ownership.epoch > hub_ownership.epoch);
|
|
|
|
let held_signals =
|
|
InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals };
|
|
firmware.tick(&held_signals, &mut platform);
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Foreground));
|
|
assert!(!platform.input().pad().any());
|
|
assert!(!platform.input().touch().f().down);
|
|
assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty());
|
|
assert_eq!(
|
|
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn game_home_shell_hub_same_game_flow_is_end_to_end_deterministic() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let game_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&signals, &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(), Some(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
|
|
firmware.tick(&signals, &mut platform);
|
|
let shell_a_click =
|
|
InputSignals { f_signal: true, x_pos: 112, y_pos: 172, ..Default::default() };
|
|
firmware.tick(&shell_a_click, &mut platform);
|
|
|
|
let shell_task = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell_task));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
|
|
let close_shell = InputSignals { start_signal: true, ..Default::default() };
|
|
firmware.tick(&close_shell, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
|
|
assert_eq!(firmware.os.lifecycle().task_state(shell_task), Some(TaskState::Closed));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
|
|
assert!(firmware.resume_resident_game_from_home());
|
|
|
|
match &firmware.state {
|
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, game_task),
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
}
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Foreground));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_game_boot_does_not_depend_on_userland_run_cart_syscall() {
|
|
assert!(Syscall::from_u32(0x0002).is_none());
|
|
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Valid Cart");
|
|
}
|
|
}
|